From f4c7ab4e306d2f04324853e67032d370abd65d0c Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Fri, 20 May 2022 15:37:08 +0200
Subject: chore: handle blog pagination when JS is disabled

---
 src/pages/blog/page/[number].tsx | 311 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 311 insertions(+)
 create mode 100644 src/pages/blog/page/[number].tsx

(limited to 'src/pages/blog/page/[number].tsx')

diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
new file mode 100644
index 0000000..e8c93f7
--- /dev/null
+++ b/src/pages/blog/page/[number].tsx
@@ -0,0 +1,311 @@
+import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
+import PageLayout from '@components/templates/page/page-layout';
+import { type EdgesResponse } from '@services/graphql/api';
+import {
+  getArticleFromRawData,
+  getArticles,
+  getArticlesEndCursor,
+  getTotalArticles,
+} from '@services/graphql/articles';
+import {
+  getThematicsPreview,
+  getTotalThematics,
+} from '@services/graphql/thematics';
+import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import {
+  type Article,
+  type Meta,
+  type NextPageWithLayout,
+} from '@ts/types/app';
+import {
+  type RawArticle,
+  type RawThematicPreview,
+  type RawTopicPreview,
+} from '@ts/types/raw-data';
+import { settings } from '@utils/config';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import {
+  getLinksListItems,
+  getPageLinkFromRawData,
+} from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
+import useRedirection from '@utils/hooks/use-redirection';
+import useSettings from '@utils/hooks/use-settings';
+import { GetStaticPaths, GetStaticProps } from 'next';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import Script from 'next/script';
+import { ParsedUrlQuery } from 'querystring';
+import { useIntl } from 'react-intl';
+import { Blog, Graph, WebPage } from 'schema-dts';
+
+type BlogPageProps = {
+  articles: EdgesResponse<RawArticle>;
+  pageNumber: number;
+  thematicsList: RawThematicPreview[];
+  topicsList: RawTopicPreview[];
+  totalArticles: number;
+  translation: Messages;
+};
+
+/**
+ * Blog index page.
+ */
+const BlogPage: NextPageWithLayout<BlogPageProps> = ({
+  articles,
+  pageNumber,
+  thematicsList,
+  topicsList,
+  totalArticles,
+}) => {
+  useRedirection({
+    query: { param: 'number', value: '1' },
+    redirectTo: '/blog',
+  });
+
+  const intl = useIntl();
+  const title = intl.formatMessage({
+    defaultMessage: 'Blog',
+    description: 'BlogPage: page title',
+    id: '7TbbIk',
+  });
+  const pageNumberTitle = intl.formatMessage(
+    {
+      defaultMessage: 'Page {number}',
+      id: 'zbzlb1',
+      description: 'BlogPage: page number',
+    },
+    {
+      number: pageNumber,
+    }
+  );
+  const pageTitleWithPageNumber = `${title} - ${pageNumberTitle}`;
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title: pageNumberTitle,
+    url: `/blog/page/${pageNumber}`,
+  });
+
+  const { website } = useSettings();
+  const { asPath } = useRouter();
+  const pageTitle = `${pageTitleWithPageNumber} - ${website.name}`;
+  const pageDescription = intl.formatMessage(
+    {
+      defaultMessage:
+        "Discover {websiteName}'s writings. He talks about web development, Linux and open source mostly.",
+      description: 'BlogPage: SEO - Meta description',
+      id: '18h/t0',
+    },
+    { websiteName: website.name }
+  );
+  const pageUrl = `${website.url}${asPath}`;
+
+  const webpageSchema: WebPage = {
+    '@id': `${pageUrl}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+    name: pageTitle,
+    description: pageDescription,
+    inLanguage: website.locales.default,
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${website.url}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const blogSchema: Blog = {
+    '@id': `${website.url}/#blog`,
+    '@type': 'Blog',
+    author: { '@id': `${website.url}/#branding` },
+    creator: { '@id': `${website.url}/#branding` },
+    editor: { '@id': `${website.url}/#branding` },
+    inLanguage: website.locales.default,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: { '@id': `${pageUrl}` },
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema, blogSchema],
+  };
+
+  /**
+   * Retrieve the formatted meta.
+   *
+   * @param {Meta<'article'>} meta - The article meta.
+   * @returns {Post['meta']} The formatted meta.
+   */
+  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
+    const { commentsCount, dates, thematics, wordsCount } = meta;
+
+    return {
+      commentsCount,
+      dates,
+      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
+      thematics: thematics?.map((thematic) => {
+        return { ...thematic, url: `/thematique/${thematic.slug}` };
+      }),
+    };
+  };
+
+  /**
+   * Retrieve the formatted posts.
+   *
+   * @param {Article[]} posts - An array of articles.
+   * @returns {Post[]} An array of formatted posts.
+   */
+  const getPosts = (posts: Article[]): Post[] => {
+    return posts.map((post) => {
+      return {
+        ...post,
+        cover: post.meta.cover,
+        excerpt: post.intro,
+        meta: getPostMeta(post.meta),
+        url: `/article/${post.slug}`,
+      };
+    });
+  };
+
+  /**
+   * Retrieve the posts list from raw data.
+   *
+   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
+   * @returns {Post[]} An array of posts.
+   */
+  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
+    const articlesList: RawArticle[] = [];
+    rawData.forEach((articleData) =>
+      articleData.edges.forEach((edge) => {
+        articlesList.push(edge.node);
+      })
+    );
+
+    return getPosts(
+      articlesList.map((article) => getArticleFromRawData(article))
+    );
+  };
+
+  const thematicsListTitle = intl.formatMessage({
+    defaultMessage: 'Thematics',
+    description: 'BlogPage: thematics list widget title',
+    id: 'HriY57',
+  });
+
+  const topicsListTitle = intl.formatMessage({
+    defaultMessage: 'Topics',
+    description: 'BlogPage: topics list widget title',
+    id: '2D9tB5',
+  });
+
+  return (
+    <>
+      <Head>
+        <title>{pageTitle}</title>
+        <meta name="description" content={pageDescription} />
+        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:type" content="website" />
+        <meta property="og:title" content={pageTitleWithPageNumber} />
+        <meta property="og:description" content={pageDescription} />
+      </Head>
+      <Script
+        id="schema-blog"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <PageLayout
+        title={pageTitleWithPageNumber}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
+        headerMeta={{ total: totalArticles }}
+        widgets={[
+          <LinksListWidget
+            key="thematics-list"
+            items={getLinksListItems(
+              thematicsList.map(getPageLinkFromRawData),
+              'thematic'
+            )}
+            title={thematicsListTitle}
+            level={2}
+          />,
+          <LinksListWidget
+            key="topics-list"
+            items={getLinksListItems(
+              topicsList.map(getPageLinkFromRawData),
+              'topic'
+            )}
+            title={topicsListTitle}
+            level={2}
+          />,
+        ]}
+      >
+        <PostsList
+          baseUrl="/blog/page/"
+          byYear={true}
+          pageNumber={pageNumber}
+          posts={getPostsList([articles])}
+          total={totalArticles}
+        />
+      </PageLayout>
+    </>
+  );
+};
+
+BlogPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
+interface BlogPageParams extends ParsedUrlQuery {
+  number: string;
+}
+
+export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
+  locale,
+  params,
+}) => {
+  const pageNumber = Number(params!.number as BlogPageParams['number']);
+  const queriedPostsNumber = settings.postsPerPage * pageNumber;
+  const lastCursor = await getArticlesEndCursor({
+    first: queriedPostsNumber,
+  });
+  const articles = await getArticles({
+    first: settings.postsPerPage,
+    after: lastCursor,
+  });
+  const totalArticles = await getTotalArticles();
+  const totalThematics = await getTotalThematics();
+  const thematics = await getThematicsPreview({ first: totalThematics });
+  const totalTopics = await getTotalTopics();
+  const topics = await getTopicsPreview({ first: totalTopics });
+  const translation = await loadTranslation(locale);
+
+  return {
+    props: {
+      articles: JSON.parse(JSON.stringify(articles)),
+      pageNumber,
+      thematicsList: thematics.edges.map((edge) => edge.node),
+      topicsList: topics.edges.map((edge) => edge.node),
+      totalArticles,
+      translation,
+    },
+  };
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+  const totalArticles = await getTotalArticles();
+  const totalPages = Math.ceil(totalArticles / settings.postsPerPage);
+  const pagesArray = Array.from(
+    { length: totalPages },
+    (_, index) => index + 1
+  );
+  const paths = pagesArray.map((number) => {
+    return { params: { number: `${number}` } };
+  });
+
+  return {
+    paths,
+    fallback: false,
+  };
+};
+
+export default BlogPage;
-- 
cgit v1.2.3


From 34e216546151eaf8a0a3cbb0bc8b65dae4c63bf2 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 23 May 2022 14:07:02 +0200
Subject: refactor: reduce the number of data transformation

---
 src/components/molecules/layout/card.tsx           |  22 +---
 src/components/organisms/layout/cards-list.tsx     |  36 +++---
 .../organisms/layout/comment.fixture.tsx           |  41 +++++++
 .../organisms/layout/comment.stories.tsx           |  28 ++---
 src/components/organisms/layout/comment.test.tsx   |  39 ++-----
 src/components/organisms/layout/comment.tsx        |  98 ++++++++---------
 .../organisms/layout/comments-list.fixture.tsx     | 106 ++++++++++++++++++
 .../organisms/layout/comments-list.stories.tsx     |  59 +---------
 .../organisms/layout/comments-list.test.tsx        |  64 +----------
 src/components/organisms/layout/comments-list.tsx  |  11 +-
 .../organisms/layout/posts-list.fixture.tsx        |  61 +++++++++++
 .../organisms/layout/posts-list.stories.tsx        |  64 +----------
 .../organisms/layout/posts-list.test.tsx           |  64 +----------
 src/components/organisms/layout/posts-list.tsx     |   4 +-
 .../organisms/layout/summary.fixture.tsx           |  25 +++++
 .../organisms/layout/summary.stories.tsx           |  28 +----
 src/components/organisms/layout/summary.test.tsx   |  44 ++------
 src/components/organisms/layout/summary.tsx        |  73 ++++---------
 .../templates/page/page-layout.stories.tsx         | 121 +--------------------
 src/components/templates/page/page-layout.test.tsx |  13 +--
 src/pages/article/[slug].tsx                       |  43 ++------
 src/pages/blog/index.tsx                           |  75 +------------
 src/pages/blog/page/[number].tsx                   |  66 +----------
 src/pages/projets/[slug].tsx                       |  14 ++-
 src/pages/projets/index.tsx                        |   4 +-
 src/pages/recherche/index.tsx                      |  76 +------------
 src/pages/sujet/[slug].tsx                         |  28 +----
 src/pages/thematique/[slug].tsx                    |  28 +----
 src/services/graphql/articles.query.ts             |   7 --
 src/services/graphql/articles.ts                   |   3 +-
 src/services/graphql/thematics.query.ts            |   9 --
 src/services/graphql/thematics.ts                  |   1 -
 src/services/graphql/topics.query.ts               |   9 --
 src/services/graphql/topics.ts                     |   1 -
 src/ts/types/app.ts                                |   5 +-
 src/ts/types/mdx.ts                                |   5 +-
 src/ts/types/raw-data.ts                           |   3 +-
 src/utils/helpers/pages.ts                         |  49 ++++++---
 38 files changed, 456 insertions(+), 971 deletions(-)
 create mode 100644 src/components/organisms/layout/comment.fixture.tsx
 create mode 100644 src/components/organisms/layout/comments-list.fixture.tsx
 create mode 100644 src/components/organisms/layout/posts-list.fixture.tsx
 create mode 100644 src/components/organisms/layout/summary.fixture.tsx

(limited to 'src/pages/blog/page/[number].tsx')

diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
index e465f76..68e364a 100644
--- a/src/components/molecules/layout/card.tsx
+++ b/src/components/molecules/layout/card.tsx
@@ -1,5 +1,6 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import { type Image } from '@ts/types/app';
 import { FC } from 'react';
 import ResponsiveImage, {
   type ResponsiveImageProps,
@@ -7,25 +8,6 @@ import ResponsiveImage, {
 import styles from './card.module.scss';
 import Meta, { type MetaData } from './meta';
 
-export type Cover = {
-  /**
-   * The cover alternative text.
-   */
-  alt: string;
-  /**
-   * The cover height.
-   */
-  height: number;
-  /**
-   * The cover source.
-   */
-  src: string;
-  /**
-   * The cover width.
-   */
-  width: number;
-};
-
 export type CardProps = {
   /**
    * Set additional classnames to the card wrapper.
@@ -34,7 +16,7 @@ export type CardProps = {
   /**
    * The card cover.
    */
-  cover?: Cover;
+  cover?: Image;
   /**
    * The cover fit. Default: cover.
    */
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index 0168bb2..1feddd0 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -10,32 +10,24 @@ export type CardsListItem = Omit<
   CardProps,
   'className' | 'coverFit' | 'titleLevel'
 > & {
-  id: string;
-};
-
-export type CardsListProps = {
-  /**
-   * Set additional classnames to the list wrapper.
-   */
-  className?: string;
-  /**
-   * The cover fit.
-   */
-  coverFit?: CardProps['coverFit'];
   /**
-   * The cards data.
+   * The card id.
    */
-  items: CardsListItem[];
-  /**
-   * The list kind. Either ordered or unordered.
-   */
-  kind?: ListProps['kind'];
-  /**
-   * The title level (hn).
-   */
-  titleLevel: CardProps['titleLevel'];
+  id: string;
 };
 
+export type CardsListProps = Pick<CardProps, 'coverFit' | 'titleLevel'> &
+  Pick<ListProps, 'kind'> & {
+    /**
+     * Set additional classnames to the list wrapper.
+     */
+    className?: string;
+    /**
+     * The cards data.
+     */
+    items: CardsListItem[];
+  };
+
 /**
  * CardsList component
  *
diff --git a/src/components/organisms/layout/comment.fixture.tsx b/src/components/organisms/layout/comment.fixture.tsx
new file mode 100644
index 0000000..0118139
--- /dev/null
+++ b/src/components/organisms/layout/comment.fixture.tsx
@@ -0,0 +1,41 @@
+import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
+import { CommentProps } from './comment';
+
+export const author = {
+  avatar: {
+    alt: 'Author avatar',
+    height: 480,
+    src: 'http://placeimg.com/640/480',
+    width: 640,
+  },
+  name: 'Armand',
+  website: 'https://www.armandphilippot.com/',
+};
+
+export const content =
+  'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
+
+export const date = '2021-04-03 23:04:24';
+
+export const meta = {
+  author,
+  date,
+};
+
+export const id = 5;
+
+export const saveComment = async () => {
+  /** Do nothing. */
+};
+
+export const data: CommentProps = {
+  approved: true,
+  content,
+  id,
+  meta,
+  parentId: 0,
+  saveComment,
+};
+
+export const formattedDate = getFormattedDate(date);
+export const formattedTime = getFormattedTime(date);
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index fa4bba9..7a8ac95 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import CommentComponent from './comment';
+import { data } from './comment.fixture';
 
 const saveComment = async () => {
   /** Do nothing. */
@@ -9,7 +10,7 @@ const saveComment = async () => {
  * Comment - Storybook Meta
  */
 export default {
-  title: 'Organisms/Layout',
+  title: 'Organisms/Layout/Comment',
   component: CommentComponent,
   args: {
     canReply: true,
@@ -110,17 +111,18 @@ const Template: ComponentStory<typeof CommentComponent> = (args) => (
 );
 
 /**
- * Layout Stories - Comment
+ * Layout Stories - Approved
  */
-export const Comment = Template.bind({});
-Comment.args = {
-  author: {
-    avatar: 'http://placeimg.com/640/480',
-    name: 'Armand',
-    url: 'https://www.armandphilippot.com/',
-  },
-  content:
-    'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.',
-  id: 2,
-  publication: '2021-04-03 23:04:24',
+export const Approved = Template.bind({});
+Approved.args = {
+  ...data,
+};
+
+/**
+ * Layout Stories - Unapproved
+ */
+export const Unapproved = Template.bind({});
+Unapproved.args = {
+  ...data,
+  approved: false,
 };
diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx
index 02a51dc..490a52b 100644
--- a/src/components/organisms/layout/comment.test.tsx
+++ b/src/components/organisms/layout/comment.test.tsx
@@ -1,48 +1,31 @@
 import { render, screen } from '@test-utils';
-import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
 import Comment from './comment';
-
-const author = {
-  avatar: 'http://placeimg.com/640/480',
-  name: 'Your name',
-  url: 'https://www.example.test/',
-};
-const content =
-  'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
-const publication = '2021-04-03 23:04:24';
-const id = 5;
-const saveComment = async () => {
-  /** Do nothing. */
-};
-const data = {
+import {
   author,
-  content,
+  data,
+  formattedDate,
+  formattedTime,
   id,
-  publication,
-  saveComment,
-};
-
-const formattedDate = getFormattedDate(publication);
-const formattedTime = getFormattedTime(publication);
+} from './comment.fixture';
 
 describe('Comment', () => {
   it('renders an avatar', () => {
-    render(<Comment {...data} />);
+    render(<Comment canReply={true} {...data} />);
     expect(
       screen.getByRole('img', { name: 'Your name avatar' })
     ).toBeInTheDocument();
   });
 
   it('renders the author website url', () => {
-    render(<Comment {...data} />);
+    render(<Comment canReply={true} {...data} />);
     expect(screen.getByRole('link', { name: author.name })).toHaveAttribute(
       'href',
-      author.url
+      author.website
     );
   });
 
   it('renders a permalink to the comment', () => {
-    render(<Comment {...data} />);
+    render(<Comment canReply={true} {...data} />);
     expect(
       screen.getByRole('link', {
         name: `${formattedDate} at ${formattedTime}`,
@@ -51,12 +34,12 @@ describe('Comment', () => {
   });
 
   it('renders a reply button', () => {
-    render(<Comment {...data} canReply={true} />);
+    render(<Comment canReply={true} {...data} />);
     expect(screen.getByRole('button', { name: 'Reply' })).toBeInTheDocument();
   });
 
   it('does not render a reply button', () => {
-    render(<Comment {...data} canReply={false} />);
+    render(<Comment canReply={false} {...data} />);
     expect(
       screen.queryByRole('button', { name: 'Reply' })
     ).not.toBeInTheDocument();
diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx
index 6df393b..1dc2c71 100644
--- a/src/components/organisms/layout/comment.tsx
+++ b/src/components/organisms/layout/comment.tsx
@@ -1,6 +1,7 @@
 import Button from '@components/atoms/buttons/button';
 import Link from '@components/atoms/links/link';
 import Meta from '@components/molecules/layout/meta';
+import { type Comment as CommentType } from '@ts/types/app';
 import useSettings from '@utils/hooks/use-settings';
 import Image from 'next/image';
 import Script from 'next/script';
@@ -25,32 +26,16 @@ export type CommentAuthor = {
   url?: string;
 };
 
-export type CommentProps = Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
-  /**
-   * The comment author data.
-   */
-  author: CommentAuthor;
-  /**
-   * Enable or disable the reply button. Default: true.
-   */
-  canReply?: boolean;
-  /**
-   * The comment body.
-   */
-  content: string;
-  /**
-   * The comment id.
-   */
-  id: number | string;
-  /**
-   * The comment parent id.
-   */
-  parentId?: number | string;
-  /**
-   * The comment date and time separated with a space.
-   */
-  publication: string;
-};
+export type CommentProps = Pick<
+  CommentType,
+  'approved' | 'content' | 'id' | 'meta' | 'parentId'
+> &
+  Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
+    /**
+     * Enable or disable the reply button. Default: true.
+     */
+    canReply?: boolean;
+  };
 
 /**
  * Comment component
@@ -58,19 +43,34 @@ export type CommentProps = Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
  * Render a single comment.
  */
 const Comment: FC<CommentProps> = ({
-  author,
+  approved,
   canReply = true,
   content,
   id,
+  meta,
   Notice,
   parentId,
-  publication,
   saveComment,
   ...props
 }) => {
   const intl = useIntl();
+  const { website } = useSettings();
   const [isReplying, setIsReplying] = useState<boolean>(false);
-  const [publicationDate, publicationTime] = publication.split(' ');
+
+  if (!approved) {
+    return (
+      <div className={styles.wrapper}>
+        {intl.formatMessage({
+          defaultMessage: 'This comment is awaiting moderation...',
+          description: 'Comment: awaiting moderation',
+          id: '6a1Uo6',
+        })}
+      </div>
+    );
+  }
+
+  const { author, date } = meta;
+  const [publicationDate, publicationTime] = date.split(' ');
 
   const avatarAltText = intl.formatMessage(
     {
@@ -97,8 +97,6 @@ const Comment: FC<CommentProps> = ({
     id: '2fD5CI',
   });
 
-  const { website } = useSettings();
-
   const commentSchema: WithContext<CommentSchema> = {
     '@context': 'https://schema.org',
     '@id': `${website.url}/#comment-${id}`,
@@ -110,17 +108,17 @@ const Comment: FC<CommentProps> = ({
     author: {
       '@type': 'Person',
       name: author.name,
-      image: author.avatar,
-      url: author.url,
+      image: author.avatar?.src,
+      url: author.website,
     },
     creator: {
       '@type': 'Person',
       name: author.name,
-      image: author.avatar,
-      url: author.url,
+      image: author.avatar?.src,
+      url: author.website,
     },
-    dateCreated: publication,
-    datePublished: publication,
+    dateCreated: date,
+    datePublished: date,
     text: content,
   };
 
@@ -136,17 +134,19 @@ const Comment: FC<CommentProps> = ({
         className={`${styles.wrapper} ${styles['wrapper--comment']}`}
       >
         <header className={styles.header}>
-          <div className={styles.avatar}>
-            <Image
-              src={author.avatar}
-              alt={avatarAltText}
-              layout="fill"
-              objectFit="cover"
-              {...props}
-            />
-          </div>
-          {author.url ? (
-            <Link href={author.url} className={styles.author}>
+          {author.avatar && (
+            <div className={styles.avatar}>
+              <Image
+                src={author.avatar?.src}
+                alt={avatarAltText}
+                layout="fill"
+                objectFit="cover"
+                {...props}
+              />
+            </div>
+          )}
+          {author.website ? (
+            <Link href={author.website} className={styles.author}>
               {author.name}
             </Link>
           ) : (
@@ -181,7 +181,7 @@ const Comment: FC<CommentProps> = ({
       {isReplying && (
         <CommentForm
           Notice={Notice}
-          parentId={id as number}
+          parentId={id}
           saveComment={saveComment}
           title={formTitle}
           className={`${styles.wrapper} ${styles['wrapper--form']}`}
diff --git a/src/components/organisms/layout/comments-list.fixture.tsx b/src/components/organisms/layout/comments-list.fixture.tsx
new file mode 100644
index 0000000..2618f77
--- /dev/null
+++ b/src/components/organisms/layout/comments-list.fixture.tsx
@@ -0,0 +1,106 @@
+import { Comment } from '@ts/types/app';
+
+export const comments: Comment[] = [
+  {
+    approved: true,
+    content:
+      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
+    id: 1,
+    meta: {
+      author: {
+        avatar: {
+          alt: 'Author 1 avatar',
+          height: 480,
+          src: 'http://placeimg.com/640/480',
+          width: 640,
+        },
+        name: 'Author 1',
+      },
+      date: '2021-04-03 18:04:11',
+    },
+    parentId: 0,
+    replies: [],
+  },
+  {
+    approved: true,
+    content:
+      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
+    id: 2,
+    meta: {
+      author: {
+        avatar: {
+          alt: 'Author 2 avatar',
+          height: 480,
+          src: 'http://placeimg.com/640/480',
+          width: 640,
+        },
+        name: 'Author 2',
+        website: '#',
+      },
+      date: '2021-04-03 23:30:20',
+    },
+    parentId: 0,
+    replies: [
+      {
+        approved: true,
+        content:
+          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
+        id: 4,
+        meta: {
+          author: {
+            avatar: {
+              alt: 'Author 4 avatar',
+              height: 480,
+              src: 'http://placeimg.com/640/480',
+              width: 640,
+            },
+            name: 'Author 4',
+          },
+          date: '2021-04-03 23:04:24',
+        },
+        parentId: 2,
+        replies: [],
+      },
+      {
+        approved: true,
+        content:
+          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
+        id: 5,
+        meta: {
+          author: {
+            avatar: {
+              alt: 'Author 1 avatar',
+              height: 480,
+              src: 'http://placeimg.com/640/480',
+              width: 640,
+            },
+            name: 'Author 1',
+          },
+          date: '2021-04-04 08:05:14',
+        },
+        parentId: 2,
+        replies: [],
+      },
+    ],
+  },
+  {
+    approved: false,
+    content:
+      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
+    id: 3,
+    meta: {
+      author: {
+        avatar: {
+          alt: 'Author 3',
+          height: 480,
+          src: 'http://placeimg.com/640/480',
+          width: 640,
+        },
+        name: 'Author 3',
+      },
+      date: '2021-09-13 13:24:54',
+    },
+    parentId: 0,
+    replies: [],
+  },
+];
diff --git a/src/components/organisms/layout/comments-list.stories.tsx b/src/components/organisms/layout/comments-list.stories.tsx
index 22cc9d9..5ed0f2a 100644
--- a/src/components/organisms/layout/comments-list.stories.tsx
+++ b/src/components/organisms/layout/comments-list.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import CommentsListComponent, { type Comment } from './comments-list';
+import CommentsListComponent from './comments-list';
+import { comments } from './comments-list.fixture';
 
 const saveComment = async () => {
   /** Do nothing. */
@@ -71,62 +72,6 @@ const Template: ComponentStory<typeof CommentsListComponent> = (args) => (
   <CommentsListComponent {...args} />
 );
 
-const comments: Comment[] = [
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 1',
-    },
-    content:
-      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-    id: 1,
-    publication: '2021-04-03 18:04:11',
-  },
-  {
-    child: [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 4',
-        },
-        content:
-          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
-        id: 4,
-        publication: '2021-04-03 23:04:24',
-      },
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
-        id: 5,
-        publication: '2021-04-04 08:05:14',
-      },
-    ],
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 2',
-      url: '#',
-    },
-    content:
-      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
-    id: 2,
-    publication: '2021-04-03 23:30:20',
-  },
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 3',
-    },
-    content:
-      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
-    id: 3,
-    publication: '2021-09-13 13:24:54',
-  },
-];
-
 /**
  * Layout Stories - Without child comments
  */
diff --git a/src/components/organisms/layout/comments-list.test.tsx b/src/components/organisms/layout/comments-list.test.tsx
index e135ec9..b0a2467 100644
--- a/src/components/organisms/layout/comments-list.test.tsx
+++ b/src/components/organisms/layout/comments-list.test.tsx
@@ -1,65 +1,7 @@
 import { render } from '@test-utils';
-import CommentsList, { type Comment } from './comments-list';
-
-const comments: Comment[] = [
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 1',
-    },
-    content:
-      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-    id: 1,
-    publication: '2021-04-03 18:04:11',
-  },
-  {
-    child: [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 4',
-        },
-        content:
-          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
-        id: 4,
-        publication: '2021-04-03 23:04:24',
-      },
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
-        id: 5,
-        publication: '2021-04-04 08:05:14',
-      },
-    ],
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 2',
-      url: '#',
-    },
-    content:
-      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
-    id: 2,
-    publication: '2021-04-03 23:30:20',
-  },
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 3',
-    },
-    content:
-      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
-    id: 3,
-    publication: '2021-05-13 13:24:54',
-  },
-];
-
-const saveComment = async () => {
-  /** Do nothing. */
-};
+import { saveComment } from './comment.fixture';
+import CommentsList from './comments-list';
+import { comments } from './comments-list.fixture';
 
 describe('CommentsList', () => {
   it('renders a comments list', () => {
diff --git a/src/components/organisms/layout/comments-list.tsx b/src/components/organisms/layout/comments-list.tsx
index f04354c..97eccb7 100644
--- a/src/components/organisms/layout/comments-list.tsx
+++ b/src/components/organisms/layout/comments-list.tsx
@@ -1,13 +1,10 @@
 import SingleComment, {
   type CommentProps,
 } from '@components/organisms/layout/comment';
+import { Comment } from '@ts/types/app';
 import { FC } from 'react';
 import styles from './comments-list.module.scss';
 
-export type Comment = Omit<CommentProps, 'canReply' | 'saveComment'> & {
-  child?: Comment[];
-};
-
 export type CommentsListProps = Pick<CommentProps, 'Notice' | 'saveComment'> & {
   /**
    * An array of comments.
@@ -42,7 +39,7 @@ const CommentsList: FC<CommentsListProps> = ({
   ): JSX.Element[] => {
     const isLastLevel = startLevel === depth;
 
-    return commentsList.map(({ child, ...comment }) => (
+    return commentsList.map(({ replies, ...comment }) => (
       <li key={comment.id} className={styles.item}>
         <SingleComment
           canReply={!isLastLevel}
@@ -50,8 +47,8 @@ const CommentsList: FC<CommentsListProps> = ({
           saveComment={saveComment}
           {...comment}
         />
-        {child && !isLastLevel && (
-          <ol className={styles.list}>{getItems(child, startLevel + 1)}</ol>
+        {replies && !isLastLevel && (
+          <ol className={styles.list}>{getItems(replies, startLevel + 1)}</ol>
         )}
       </li>
     ));
diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx
new file mode 100644
index 0000000..b0afdf8
--- /dev/null
+++ b/src/components/organisms/layout/posts-list.fixture.tsx
@@ -0,0 +1,61 @@
+import { type Post } from './posts-list';
+
+export const introPost1 =
+  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
+
+export const introPost2 =
+  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
+
+export const introPost3 =
+  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
+
+export const cover = {
+  alt: 'cover',
+  height: 480,
+  src: 'http://placeimg.com/640/480',
+  width: 640,
+};
+
+export const posts: Post[] = [
+  {
+    intro: introPost1,
+    id: 'post-1',
+    meta: {
+      cover,
+      dates: { publication: '2022-02-26' },
+      wordsCount: introPost1.split(' ').length,
+      thematics: [
+        { id: 1, name: 'Cat 1', slug: '#' },
+        { id: 2, name: 'Cat 2', slug: '#' },
+      ],
+      commentsCount: 1,
+    },
+    title: 'Ratione velit fuga',
+    url: '#',
+  },
+  {
+    intro: introPost2,
+    id: 'post-2',
+    meta: {
+      dates: { publication: '2022-02-20' },
+      wordsCount: introPost2.split(' ').length,
+      thematics: [{ id: 2, name: 'Cat 2', slug: '#' }],
+      commentsCount: 0,
+    },
+    title: 'Debitis laudantium laudantium',
+    url: '#',
+  },
+  {
+    intro: introPost3,
+    id: 'post-3',
+    meta: {
+      cover,
+      dates: { publication: '2021-12-20' },
+      wordsCount: introPost3.split(' ').length,
+      thematics: [{ id: 1, name: 'Cat 1', slug: '#' }],
+      commentsCount: 3,
+    },
+    title: 'Quaerat ut corporis',
+    url: '#',
+  },
+];
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index 360d441..68e3dc0 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import PostsList, { type Post } from './posts-list';
+import PostsList from './posts-list';
+import { posts } from './posts-list.fixture';
 
 /**
  * PostsList - Storybook Meta
@@ -154,67 +155,6 @@ const Template: ComponentStory<typeof PostsList> = (args) => (
   <PostsList {...args} />
 );
 
-const excerpt1 =
-  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
-const excerpt2 =
-  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
-const excerpt3 =
-  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
-
-const posts: Post[] = [
-  {
-    excerpt: excerpt1,
-    id: 'post-1',
-    meta: {
-      dates: { publication: '2022-02-26' },
-      readingTime: { wordsCount: excerpt1.split(' ').length },
-      thematics: [
-        { id: 'cat-1', name: 'Cat 1', url: '#' },
-        { id: 'cat-2', name: 'Cat 2', url: '#' },
-      ],
-      commentsCount: 1,
-    },
-    title: 'Ratione velit fuga',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-  {
-    excerpt: excerpt2,
-    id: 'post-2',
-    meta: {
-      dates: { publication: '2022-02-20' },
-      readingTime: { wordsCount: excerpt2.split(' ').length },
-      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
-      commentsCount: 0,
-    },
-    title: 'Debitis laudantium laudantium',
-    url: '#',
-  },
-  {
-    excerpt: excerpt3,
-    id: 'post-3',
-    meta: {
-      dates: { publication: '2021-12-20' },
-      readingTime: { wordsCount: excerpt3.split(' ').length },
-      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
-      commentsCount: 3,
-    },
-    title: 'Quaerat ut corporis',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-];
-
 /**
  * PostsList Stories - Default
  */
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
index dfb9825..1bab466 100644
--- a/src/components/organisms/layout/posts-list.test.tsx
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -1,66 +1,6 @@
 import { render, screen } from '@test-utils';
-import PostsList, { Post } from './posts-list';
-
-const excerpt1 =
-  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
-const excerpt2 =
-  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
-const excerpt3 =
-  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
-
-const posts: Post[] = [
-  {
-    excerpt: excerpt1,
-    id: 'post-1',
-    meta: {
-      dates: { publication: '2022-02-26' },
-      readingTime: { wordsCount: excerpt1.split(' ').length },
-      thematics: [
-        { id: 'cat-1', name: 'Cat 1', url: '#' },
-        { id: 'cat-2', name: 'Cat 2', url: '#' },
-      ],
-      commentsCount: 1,
-    },
-    title: 'Ratione velit fuga',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-  {
-    excerpt: excerpt2,
-    id: 'post-2',
-    meta: {
-      dates: { publication: '2022-02-20' },
-      readingTime: { wordsCount: excerpt2.split(' ').length },
-      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
-      commentsCount: 0,
-    },
-    title: 'Debitis laudantium laudantium',
-    url: '#',
-  },
-  {
-    excerpt: excerpt3,
-    id: 'post-3',
-    meta: {
-      dates: { publication: '2021-12-20' },
-      readingTime: { wordsCount: excerpt3.split(' ').length },
-      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
-      commentsCount: 3,
-    },
-    title: 'Quaerat ut corporis',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-];
+import PostsList from './posts-list';
+import { posts } from './posts-list.fixture';
 
 describe('PostsList', () => {
   it('renders the correct number of posts', () => {
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index 608130e..91fc62d 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -3,7 +3,7 @@ import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import ProgressBar from '@components/atoms/loaders/progress-bar';
 import Spinner from '@components/atoms/loaders/spinner';
 import Pagination, {
-  PaginationProps,
+  type PaginationProps,
 } from '@components/molecules/nav/pagination';
 import useIsMounted from '@utils/hooks/use-is-mounted';
 import useSettings from '@utils/hooks/use-settings';
@@ -12,7 +12,7 @@ import { useIntl } from 'react-intl';
 import styles from './posts-list.module.scss';
 import Summary, { type SummaryProps } from './summary';
 
-export type Post = SummaryProps & {
+export type Post = Omit<SummaryProps, 'titleLevel'> & {
   /**
    * The post id.
    */
diff --git a/src/components/organisms/layout/summary.fixture.tsx b/src/components/organisms/layout/summary.fixture.tsx
new file mode 100644
index 0000000..fbc46a5
--- /dev/null
+++ b/src/components/organisms/layout/summary.fixture.tsx
@@ -0,0 +1,25 @@
+import { type SummaryMeta } from './summary';
+
+export const cover = {
+  alt: 'A cover',
+  height: 480,
+  src: 'http://placeimg.com/640/480',
+  width: 640,
+};
+
+export const intro =
+  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
+
+export const meta: SummaryMeta = {
+  dates: { publication: '2022-04-11' },
+  wordsCount: intro.split(' ').length,
+  thematics: [
+    { id: 1, name: 'Cat 1', slug: '#' },
+    { id: 2, name: 'Cat 2', slug: '#' },
+  ],
+  commentsCount: 1,
+};
+
+export const title = 'Odio odit necessitatibus';
+
+export const url = '#';
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
index 2512051..0b91e24 100644
--- a/src/components/organisms/layout/summary.stories.tsx
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import Summary from './summary';
+import { cover, intro, meta } from './summary.fixture';
 
 /**
  * Summary - Storybook Meta
@@ -83,32 +84,12 @@ const Template: ComponentStory<typeof Summary> = (args) => (
   <Summary {...args} />
 );
 
-const cover = {
-  alt: 'A cover',
-  height: 480,
-  src: 'http://placeimg.com/640/480',
-  width: 640,
-};
-
-const excerpt =
-  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
-
-const meta = {
-  dates: { publication: '2022-04-11' },
-  readingTime: { wordsCount: excerpt.split(' ').length },
-  thematics: [
-    { id: 'cat-1', name: 'Cat 1', url: '#' },
-    { id: 'cat-2', name: 'Cat 2', url: '#' },
-  ],
-  commentsCount: 1,
-};
-
 /**
  * Summary Stories - Default
  */
 export const Default = Template.bind({});
 Default.args = {
-  excerpt,
+  intro,
   meta,
   title: 'Odio odit necessitatibus',
   url: '#',
@@ -119,9 +100,8 @@ Default.args = {
  */
 export const WithCover = Template.bind({});
 WithCover.args = {
-  cover,
-  excerpt,
-  meta,
+  intro,
+  meta: { ...meta, cover },
   title: 'Odio odit necessitatibus',
   url: '#',
 };
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
index 9e34254..7617c26 100644
--- a/src/components/organisms/layout/summary.test.tsx
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -1,35 +1,12 @@
 import { render, screen } from '@test-utils';
 import Summary from './summary';
-
-const cover = {
-  alt: 'A cover',
-  height: 480,
-  src: 'http://placeimg.com/640/480',
-  width: 640,
-};
-
-const excerpt =
-  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
-
-const meta = {
-  dates: { publication: '2022-04-11' },
-  readingTime: { wordsCount: excerpt.split(' ').length },
-  thematics: [
-    { id: 'cat-1', name: 'Cat 1', url: '#' },
-    { id: 'cat-2', name: 'Cat 2', url: '#' },
-  ],
-  commentsCount: 1,
-};
-
-const title = 'Odio odit necessitatibus';
-
-const url = '#';
+import { cover, intro, meta, title, url } from './summary.fixture';
 
 describe('Summary', () => {
   it('renders a title wrapped in a h2 element', () => {
     render(
       <Summary
-        excerpt={excerpt}
+        intro={intro}
         meta={meta}
         title={title}
         titleLevel={2}
@@ -42,16 +19,15 @@ describe('Summary', () => {
   });
 
   it('renders an excerpt', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
-    expect(screen.getByText(excerpt)).toBeInTheDocument();
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
+    expect(screen.getByText(intro)).toBeInTheDocument();
   });
 
   it('renders a cover', () => {
     render(
       <Summary
-        cover={cover}
-        excerpt={excerpt}
-        meta={meta}
+        intro={intro}
+        meta={{ ...meta, cover }}
         title={title}
         url={url}
       />
@@ -60,19 +36,19 @@ describe('Summary', () => {
   });
 
   it('renders a link to the full post', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
     expect(screen.getByRole('link', { name: title })).toBeInTheDocument();
   });
 
   it('renders a read more link', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
     expect(
       screen.getByRole('link', { name: `Read more about ${title}` })
     ).toBeInTheDocument();
   });
 
   it('renders some meta', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
-    expect(screen.getByText(meta.thematics[0].name)).toBeInTheDocument();
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
+    expect(screen.getByText(meta.thematics![0].name)).toBeInTheDocument();
   });
 });
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 3831c0c..ca51cd6 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -6,7 +6,7 @@ import ResponsiveImage, {
   type ResponsiveImageProps,
 } from '@components/molecules/images/responsive-image';
 import Meta, { type MetaData } from '@components/molecules/layout/meta';
-import { type Dates } from '@ts/types/app';
+import { type Article, type Meta as MetaType } from '@ts/types/app';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
@@ -17,43 +17,22 @@ export type Cover = Pick<
   'alt' | 'src' | 'width' | 'height'
 >;
 
-export type SummaryMetaLink = {
-  id: number | string;
-  name: string;
-  url: string;
-};
-
-export type SummaryMetaReadingTime = {
-  wordsCount: number;
-  onlyMinutes?: boolean;
-};
-
-export type SummaryMeta = {
-  author?: string;
-  commentsCount?: number;
-  dates: Dates;
-  readingTime: SummaryMetaReadingTime;
-  thematics?: SummaryMetaLink[];
-  topics?: SummaryMetaLink[];
-};
+export type SummaryMeta = Pick<
+  MetaType<'article'>,
+  | 'author'
+  | 'commentsCount'
+  | 'cover'
+  | 'dates'
+  | 'thematics'
+  | 'topics'
+  | 'wordsCount'
+>;
 
-export type SummaryProps = {
+export type SummaryProps = Pick<Article, 'intro' | 'title'> & {
   /**
-   * The post cover.
-   */
-  cover?: Cover;
-  /**
-   * The post excerpt.
-   */
-  excerpt: string;
-  /**
-   * The post meta.
+   * The post metadata.
    */
   meta: SummaryMeta;
-  /**
-   * The post title.
-   */
-  title: string;
   /**
    * The heading level (hn).
    */
@@ -70,8 +49,7 @@ export type SummaryProps = {
  * Render a page summary.
  */
 const Summary: FC<SummaryProps> = ({
-  cover,
-  excerpt,
+  intro,
   meta,
   title,
   titleLevel = 2,
@@ -91,14 +69,13 @@ const Summary: FC<SummaryProps> = ({
       ),
     }
   );
-  const { wordsCount, onlyMinutes } = meta.readingTime;
-  const readingTime = useReadingTime(wordsCount, onlyMinutes);
-
-  const getMeta = (data: SummaryMeta): MetaData => {
-    const { author, commentsCount, dates, thematics, topics } = data;
+  const { author, commentsCount, cover, dates, thematics, topics, wordsCount } =
+    meta;
+  const readingTime = useReadingTime(wordsCount, true);
 
+  const getMeta = (): MetaData => {
     return {
-      author,
+      author: author?.name,
       publication: { date: dates.publication },
       update:
         dates.update && dates.publication !== dates.update
@@ -106,12 +83,12 @@ const Summary: FC<SummaryProps> = ({
           : undefined,
       readingTime,
       thematics: thematics?.map((thematic) => (
-        <Link key={thematic.id} href={thematic.url}>
+        <Link key={thematic.id} href={thematic.slug}>
           {thematic.name}
         </Link>
       )),
       topics: topics?.map((topic) => (
-        <Link key={topic.id} href={topic.url}>
+        <Link key={topic.id} href={topic.slug}>
           {topic.name}
         </Link>
       )),
@@ -134,11 +111,7 @@ const Summary: FC<SummaryProps> = ({
         </Link>
       </header>
       <div className={styles.body}>
-        {typeof excerpt === 'string' ? (
-          <div dangerouslySetInnerHTML={{ __html: excerpt }} />
-        ) : (
-          excerpt
-        )}
+        <div dangerouslySetInnerHTML={{ __html: intro }} />
         <ButtonLink target={url} className={styles['read-more']}>
           <>
             {readMore}
@@ -148,7 +121,7 @@ const Summary: FC<SummaryProps> = ({
       </div>
       <footer className={styles.footer}>
         <Meta
-          data={getMeta(meta)}
+          data={getMeta()}
           layout="column"
           itemsLayout="stacked"
           withSeparator={false}
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index bc1d7b6..88e3785 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -1,7 +1,9 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading from '@components/atoms/headings/heading';
 import Link from '@components/atoms/links/link';
+import { comments } from '@components/organisms/layout/comments-list.fixture';
 import PostsList from '@components/organisms/layout/posts-list';
+import { posts } from '@components/organisms/layout/posts-list.fixture';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import Sharing from '@components/organisms/widgets/sharing';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
@@ -267,62 +269,6 @@ const postBreadcrumb = [
   { id: 'post', url: '#', name: pageTitle },
 ];
 
-const comments = [
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 1',
-    },
-    content:
-      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-    id: 1,
-    publication: '2021-04-03 18:04:11',
-  },
-  {
-    child: [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 4',
-        },
-        content:
-          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
-        id: 4,
-        publication: '2021-04-03 23:04:24',
-      },
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
-        id: 5,
-        publication: '2021-04-04 08:05:14',
-      },
-    ],
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 2',
-      url: '#',
-    },
-    content:
-      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
-    id: 2,
-    publication: '2021-04-03 23:30:20',
-  },
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 3',
-    },
-    content:
-      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
-    id: 3,
-    publication: '2021-09-13 13:24:54',
-  },
-];
-
 /**
  * Page Layout Stories - Post
  */
@@ -393,7 +339,7 @@ Post.args = {
     />,
   ],
   withToC: true,
-  comments,
+  comments: comments,
   allowComments: true,
 };
 
@@ -402,67 +348,6 @@ const postsListBreadcrumb = [
   { id: 'blog', url: '#', name: 'Blog' },
 ];
 
-const excerpt1 =
-  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
-const excerpt2 =
-  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
-const excerpt3 =
-  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
-
-const posts = [
-  {
-    excerpt: excerpt1,
-    id: 'post-1',
-    meta: {
-      dates: { publication: '2022-02-26' },
-      readingTime: { wordsCount: excerpt1.split(' ').length },
-      thematics: [
-        { id: 'cat-1', name: 'Cat 1', url: '#' },
-        { id: 'cat-2', name: 'Cat 2', url: '#' },
-      ],
-      commentsCount: 1,
-    },
-    title: 'Ratione velit fuga',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-  {
-    excerpt: excerpt2,
-    id: 'post-2',
-    meta: {
-      dates: { publication: '2022-02-20' },
-      readingTime: { wordsCount: excerpt2.split(' ').length },
-      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
-      commentsCount: 0,
-    },
-    title: 'Debitis laudantium laudantium',
-    url: '#',
-  },
-  {
-    excerpt: excerpt3,
-    id: 'post-3',
-    meta: {
-      dates: { publication: '2021-12-20' },
-      readingTime: { wordsCount: excerpt3.split(' ').length },
-      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
-      commentsCount: 3,
-    },
-    title: 'Quaerat ut corporis',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-];
-
 const blogCategories = [
   { name: 'Cat 1', url: '#' },
   {
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
index a0c8923..f2d07d7 100644
--- a/src/components/templates/page/page-layout.test.tsx
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -1,3 +1,4 @@
+import { comments } from '@components/organisms/layout/comments-list.fixture';
 import { render, screen } from '@test-utils';
 import { BreadcrumbList } from 'schema-dts';
 import PageLayout from './page-layout';
@@ -88,18 +89,6 @@ describe('PageLayout', () => {
   });
 
   it('renders the comments list', () => {
-    const comments = [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-        id: 1,
-        publication: '2021-04-03 18:04:11',
-      },
-    ];
     render(
       <PageLayout
         breadcrumb={breadcrumb}
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 812fffe..2878538 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -18,11 +18,8 @@ import {
   type NextPageWithLayout,
 } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
-import useAddClassName from '@utils/hooks/use-add-classname';
-import useAttributes from '@utils/hooks/use-attributes';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePrism, { type OptionalPrismPlugin } from '@utils/hooks/use-prism';
-import useQuerySelectorAll from '@utils/hooks/use-query-selector-all';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps } from 'next';
@@ -66,12 +63,15 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     title,
     url: `/article/${slug}`,
   });
-  const readingTime = useReadingTime(wordsCount || 0, true);
+  const readingTime = useReadingTime(wordsCount, true);
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     author: author?.name,
     publication: { date: dates.publication },
-    update: dates.update ? { date: dates.update } : undefined,
+    update:
+      dates.update && dates.publication !== dates.update
+        ? { date: dates.update }
+        : undefined,
     readingTime,
     thematics:
       thematics &&
@@ -167,35 +167,6 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     '@graph': [webpageSchema, blogSchema, blogPostSchema],
   };
 
-  /**
-   * Convert the comments list to the right format.
-   *
-   * @param {Comment[]} list - The comments list.
-   * @returns {PageLayoutProps['comments']} - The formatted comments list.
-   */
-  const getCommentsList = (list: Comment[]): PageLayoutProps['comments'] => {
-    return list.map((comment) => {
-      const {
-        content: commentBody,
-        id: commentId,
-        meta: commentMeta,
-        parentId,
-        replies,
-      } = comment;
-      const { author: commentAuthor, date } = commentMeta;
-      const { name, avatar, website: authorUrl } = commentAuthor;
-
-      return {
-        author: { name, avatar: avatar!.src, url: authorUrl },
-        content: commentBody,
-        id: commentId,
-        publication: date,
-        child: getCommentsList(replies),
-        parentId,
-      };
-    });
-  };
-
   const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers'];
   const { attributes, className } = usePrism({ plugins: prismPlugins });
   const lineNumbersClassName = className
@@ -254,7 +225,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
         bodyClassName={styles.body}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        comments={data && getCommentsList(data)}
+        comments={data}
         footerMeta={footerMeta}
         headerMeta={headerMeta}
         id={id as number}
@@ -314,7 +285,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
 
   return {
     paths,
-    fallback: true,
+    fallback: false,
   };
 };
 
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 90f56be..dd04fad 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,34 +1,27 @@
 import Notice from '@components/atoms/layout/notice';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
-import {
-  getArticleFromRawData,
-  getArticles,
-  getTotalArticles,
-} from '@services/graphql/articles';
+import { getArticles, getTotalArticles } from '@services/graphql/articles';
 import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
-  type Article,
-  type Meta,
-  type NextPageWithLayout,
-} from '@ts/types/app';
-import {
-  RawThematicPreview,
-  RawTopicPreview,
   type RawArticle,
+  type RawThematicPreview,
+  type RawTopicPreview,
 } from '@ts/types/raw-data';
 import { settings } from '@utils/config';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
+  getPostsList,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
@@ -119,62 +112,6 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     '@graph': [webpageSchema, blogSchema],
   };
 
-  /**
-   * Retrieve the formatted meta.
-   *
-   * @param {Meta<'article'>} meta - The article meta.
-   * @returns {Post['meta']} The formatted meta.
-   */
-  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-    const { commentsCount, dates, thematics, wordsCount } = meta;
-
-    return {
-      commentsCount,
-      dates,
-      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-      thematics: thematics?.map((thematic) => {
-        return { ...thematic, url: `/thematique/${thematic.slug}` };
-      }),
-    };
-  };
-
-  /**
-   * Retrieve the formatted posts.
-   *
-   * @param {Article[]} posts - An array of articles.
-   * @returns {Post[]} An array of formatted posts.
-   */
-  const getPosts = (posts: Article[]): Post[] => {
-    return posts.map((post) => {
-      return {
-        ...post,
-        cover: post.meta.cover,
-        excerpt: post.intro,
-        meta: getPostMeta(post.meta),
-        url: `/article/${post.slug}`,
-      };
-    });
-  };
-
-  /**
-   * Retrieve the posts list from raw data.
-   *
-   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
-   * @returns {Post[]} An array of posts.
-   */
-  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
-    const articlesList: RawArticle[] = [];
-    rawData.forEach((articleData) =>
-      articleData.edges.forEach((edge) => {
-        articlesList.push(edge.node);
-      })
-    );
-
-    return getPosts(
-      articlesList.map((article) => getArticleFromRawData(article))
-    );
-  };
-
   const {
     data,
     error,
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index e8c93f7..8f50ddd 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -1,10 +1,9 @@
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
-  getArticleFromRawData,
   getArticles,
   getArticlesEndCursor,
   getTotalArticles,
@@ -14,11 +13,7 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
-import {
-  type Article,
-  type Meta,
-  type NextPageWithLayout,
-} from '@ts/types/app';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
   type RawArticle,
   type RawThematicPreview,
@@ -29,6 +24,7 @@ import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
+  getPostsList,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useRedirection from '@utils/hooks/use-redirection';
@@ -131,62 +127,6 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     '@graph': [webpageSchema, blogSchema],
   };
 
-  /**
-   * Retrieve the formatted meta.
-   *
-   * @param {Meta<'article'>} meta - The article meta.
-   * @returns {Post['meta']} The formatted meta.
-   */
-  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-    const { commentsCount, dates, thematics, wordsCount } = meta;
-
-    return {
-      commentsCount,
-      dates,
-      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-      thematics: thematics?.map((thematic) => {
-        return { ...thematic, url: `/thematique/${thematic.slug}` };
-      }),
-    };
-  };
-
-  /**
-   * Retrieve the formatted posts.
-   *
-   * @param {Article[]} posts - An array of articles.
-   * @returns {Post[]} An array of formatted posts.
-   */
-  const getPosts = (posts: Article[]): Post[] => {
-    return posts.map((post) => {
-      return {
-        ...post,
-        cover: post.meta.cover,
-        excerpt: post.intro,
-        meta: getPostMeta(post.meta),
-        url: `/article/${post.slug}`,
-      };
-    });
-  };
-
-  /**
-   * Retrieve the posts list from raw data.
-   *
-   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
-   * @returns {Post[]} An array of posts.
-   */
-  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
-    const articlesList: RawArticle[] = [];
-    rawData.forEach((articleData) =>
-      articleData.edges.forEach((edge) => {
-        articlesList.push(edge.node);
-      })
-    );
-
-    return getPosts(
-      articlesList.map((article) => getArticleFromRawData(article))
-    );
-  };
-
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Thematics',
     description: 'BlogPage: thematics list widget title',
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index e9b36fa..27c715d 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -1,14 +1,18 @@
 import Link from '@components/atoms/links/link';
-import SocialLink, { SocialWebsite } from '@components/atoms/links/social-link';
+import SocialLink, {
+  type SocialWebsite,
+} from '@components/atoms/links/social-link';
 import Spinner from '@components/atoms/loaders/spinner';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Code from '@components/molecules/layout/code';
 import Gallery from '@components/organisms/images/gallery';
-import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
+import Overview, {
+  type OverviewMeta,
+} from '@components/organisms/layout/overview';
 import Sharing from '@components/organisms/widgets/sharing';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
-  PageLayoutProps,
+  type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import styles from '@styles/pages/project.module.scss';
 import {
@@ -16,11 +20,11 @@ import {
   type ProjectPreview,
   type Repos,
 } from '@ts/types/app';
-import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
 import { capitalize } from '@utils/helpers/strings';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
-import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
+import useGithubApi, { type RepoData } from '@utils/hooks/use-github-api';
 import useSettings from '@utils/hooks/use-settings';
 import { MDXComponents, NestedMDXComponents } from 'mdx/types';
 import { GetStaticPaths, GetStaticProps } from 'next';
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index d500b6b..9ca289a 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -1,13 +1,13 @@
 import Link from '@components/atoms/links/link';
 import CardsList, {
-  CardsListItem,
+  type CardsListItem,
 } from '@components/organisms/layout/cards-list';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import PageContent, { meta } from '@content/pages/projects.mdx';
 import styles from '@styles/pages/projects.module.scss';
 import { type NextPageWithLayout, type ProjectCard } from '@ts/types/app';
-import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 8895015..c69d931 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -1,34 +1,26 @@
 import Notice from '@components/atoms/layout/notice';
 import Spinner from '@components/atoms/loaders/spinner';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
-import { type EdgesResponse } from '@services/graphql/api';
-import {
-  getArticleFromRawData,
-  getArticles,
-  getTotalArticles,
-} from '@services/graphql/articles';
+import { getArticles, getTotalArticles } from '@services/graphql/articles';
 import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
-  type Article,
-  type Meta,
-  type NextPageWithLayout,
-} from '@ts/types/app';
-import {
-  RawThematicPreview,
-  RawTopicPreview,
   type RawArticle,
+  type RawThematicPreview,
+  type RawTopicPreview,
 } from '@ts/types/raw-data';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
+  getPostsList,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
@@ -145,62 +137,6 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
     getTotalArticles(query.s as string)
   );
 
-  /**
-   * Retrieve the formatted meta.
-   *
-   * @param {Meta<'article'>} meta - The article meta.
-   * @returns {Post['meta']} The formatted meta.
-   */
-  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-    const { commentsCount, dates, thematics, wordsCount } = meta;
-
-    return {
-      commentsCount,
-      dates,
-      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-      thematics: thematics?.map((thematic) => {
-        return { ...thematic, url: `/thematique/${thematic.slug}` };
-      }),
-    };
-  };
-
-  /**
-   * Retrieve the formatted posts.
-   *
-   * @param {Article[]} posts - An array of articles.
-   * @returns {Post[]} An array of formatted posts.
-   */
-  const getPosts = (posts: Article[]): Post[] => {
-    return posts.map((post) => {
-      return {
-        ...post,
-        cover: post.meta.cover,
-        excerpt: post.intro,
-        meta: getPostMeta(post.meta),
-        url: `/article/${post.slug}`,
-      };
-    });
-  };
-
-  /**
-   * Retrieve the posts list from raw data.
-   *
-   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
-   * @returns {Post[]} An array of posts.
-   */
-  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
-    const articlesList: RawArticle[] = [];
-    rawData.forEach((articleData) =>
-      articleData.edges.forEach((edge) => {
-        articlesList.push(edge.node);
-      })
-    );
-
-    return getPosts(
-      articlesList.map((article) => getArticleFromRawData(article))
-    );
-  };
-
   /**
    * Load more posts handler.
    */
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index b4845eb..95843cd 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -1,6 +1,6 @@
 import Heading from '@components/atoms/headings/heading';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
@@ -14,7 +14,6 @@ import {
 } from '@services/graphql/topics';
 import styles from '@styles/pages/topic.module.scss';
 import {
-  type Article,
   type NextPageWithLayout,
   type PageLink,
   type Topic,
@@ -23,7 +22,7 @@ import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
-  getPostMeta,
+  getPostsWithUrl,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
@@ -109,27 +108,6 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
     '@graph': [webpageSchema, articleSchema],
   };
 
-  const getPosts = (array: Article[]): Post[] => {
-    return array.map((article) => {
-      const {
-        intro: articleIntro,
-        meta: articleMeta,
-        slug: articleSlug,
-        ...remainingData
-      } = article;
-
-      const { cover: articleCover, ...remainingMeta } = articleMeta;
-
-      return {
-        cover: articleCover,
-        excerpt: articleIntro,
-        meta: getPostMeta(remainingMeta),
-        url: `/article/${articleSlug}`,
-        ...remainingData,
-      };
-    });
-  };
-
   const topicsListTitle = intl.formatMessage({
     defaultMessage: 'Other topics',
     description: 'TopicPage: other topics list widget title',
@@ -206,7 +184,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/sujet/page/"
-              posts={getPosts(articles)}
+              posts={getPostsWithUrl(articles)}
               total={articles.length}
               titleLevel={3}
               byYear={true}
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index ce4eccf..a91483e 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -1,5 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
@@ -12,7 +12,6 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import {
-  type Article,
   type NextPageWithLayout,
   type PageLink,
   type Thematic,
@@ -21,7 +20,7 @@ import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
-  getPostMeta,
+  getPostsWithUrl,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
@@ -99,27 +98,6 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
     '@graph': [webpageSchema, articleSchema],
   };
 
-  const getPosts = (array: Article[]): Post[] => {
-    return array.map((article) => {
-      const {
-        intro: articleIntro,
-        meta: articleMeta,
-        slug: articleSlug,
-        ...remainingData
-      } = article;
-
-      const { cover, ...remainingMeta } = articleMeta;
-
-      return {
-        cover,
-        excerpt: articleIntro,
-        meta: getPostMeta(remainingMeta),
-        url: `/article/${articleSlug}`,
-        ...remainingData,
-      };
-    });
-  };
-
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Other thematics',
     description: 'ThematicPage: other thematics list widget title',
@@ -187,7 +165,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/thematique/page/"
-              posts={getPosts(articles)}
+              posts={getPostsWithUrl(articles)}
               total={articles.length}
               titleLevel={3}
               byYear={true}
diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts
index 7bd0901..3e1f575 100644
--- a/src/services/graphql/articles.query.ts
+++ b/src/services/graphql/articles.query.ts
@@ -56,7 +56,6 @@ export const articleBySlugQuery = `query PostBy($slug: ID!) {
       }
     }
     info {
-      readingTime
       wordsCount
     }
     modified
@@ -90,11 +89,6 @@ export const articlesQuery = `query Articles($after: String = "", $first: Int =
             }
           }
         }
-        author {
-          node {
-            name
-          }
-        }
         commentCount
         contentParts {
           beforeMore
@@ -113,7 +107,6 @@ export const articlesQuery = `query Articles($after: String = "", $first: Int =
           }
         }
         info {
-          readingTime
           wordsCount
         }
         modified
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index f130872..f0ba4e4 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -71,13 +71,12 @@ export const getArticleFromRawData = (data: RawArticle): Article => {
     id: databaseId,
     intro: contentParts.beforeMore,
     meta: {
-      author: getAuthorFromRawData(author.node, 'page'),
+      author: author && getAuthorFromRawData(author.node, 'page'),
       commentsCount: commentCount || 0,
       cover: featuredImage?.node
         ? getImageFromRawData(featuredImage.node)
         : undefined,
       dates: { publication: date, update: modified },
-      readingTime: info.readingTime,
       seo: {
         description: seo?.metaDesc || '',
         title: seo?.title || '',
diff --git a/src/services/graphql/thematics.query.ts b/src/services/graphql/thematics.query.ts
index 3b3ebd6..5a82133 100644
--- a/src/services/graphql/thematics.query.ts
+++ b/src/services/graphql/thematics.query.ts
@@ -15,13 +15,6 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
               }
             }
           }
-          author {
-            node {
-              gravatarUrl
-              name
-              url
-            }
-          }
           commentCount
           contentParts {
             beforeMore
@@ -40,7 +33,6 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
             }
           }
           info {
-            readingTime
             wordsCount
           }
           modified
@@ -67,7 +59,6 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
       }
     }
     info {
-      readingTime
       wordsCount
     }
     modified
diff --git a/src/services/graphql/thematics.ts b/src/services/graphql/thematics.ts
index e526db8..fd9d9f5 100644
--- a/src/services/graphql/thematics.ts
+++ b/src/services/graphql/thematics.ts
@@ -117,7 +117,6 @@ export const getThematicFromRawData = (data: RawThematic): Thematic => {
         ? getImageFromRawData(featuredImage.node)
         : undefined,
       dates: { publication: date, update: modified },
-      readingTime: info.readingTime,
       seo: {
         description: seo?.metaDesc || '',
         title: seo?.title || '',
diff --git a/src/services/graphql/topics.query.ts b/src/services/graphql/topics.query.ts
index 4574256..57b2569 100644
--- a/src/services/graphql/topics.query.ts
+++ b/src/services/graphql/topics.query.ts
@@ -16,13 +16,6 @@ export const topicBySlugQuery = `query TopicBy($slug: ID!) {
               }
             }
           }
-          author {
-            node {
-              gravatarUrl
-              name
-              url
-            }
-          }
           commentCount
           contentParts {
             beforeMore
@@ -41,7 +34,6 @@ export const topicBySlugQuery = `query TopicBy($slug: ID!) {
             }
           }
           info {
-            readingTime
             wordsCount
           }
           modified
@@ -68,7 +60,6 @@ export const topicBySlugQuery = `query TopicBy($slug: ID!) {
       }
     }
     info {
-      readingTime
       wordsCount
     }
     modified
diff --git a/src/services/graphql/topics.ts b/src/services/graphql/topics.ts
index 3c92442..277580f 100644
--- a/src/services/graphql/topics.ts
+++ b/src/services/graphql/topics.ts
@@ -119,7 +119,6 @@ export const getTopicFromRawData = (data: RawTopic): Topic => {
         : undefined,
       dates: { publication: date, update: modified },
       website: acfTopics.officialWebsite,
-      readingTime: info.readingTime,
       seo: {
         description: seo?.metaDesc || '',
         title: seo?.title || '',
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index feff5a5..cb5429b 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -44,7 +44,7 @@ export type Comment = {
   content: string;
   id: number;
   meta: CommentMeta;
-  parentId: number;
+  parentId?: number;
   replies: Comment[];
 };
 
@@ -80,7 +80,6 @@ export type Meta<T extends PageKind> = {
   cover?: Image;
   dates: Dates;
   license?: T extends 'project' ? string : never;
-  readingTime?: number;
   repos?: T extends 'project' ? Repos : never;
   seo: SEO;
   tagline?: T extends 'project' ? string : never;
@@ -88,7 +87,7 @@ export type Meta<T extends PageKind> = {
   thematics?: T extends 'article' | 'topic' ? PageLink[] : never;
   topics?: T extends 'article' | 'thematic' ? PageLink[] : never;
   website?: T extends 'topic' ? string : never;
-  wordsCount?: number;
+  wordsCount: number;
 };
 
 export type Page<T extends PageKind> = {
diff --git a/src/ts/types/mdx.ts b/src/ts/types/mdx.ts
index 6b72f21..16538c1 100644
--- a/src/ts/types/mdx.ts
+++ b/src/ts/types/mdx.ts
@@ -11,10 +11,7 @@ export type MDXPageMeta = Pick<Meta<'page'>, 'cover' | 'dates' | 'seo'> & {
   title: string;
 };
 
-export type MDXProjectMeta = Omit<
-  Meta<'project'>,
-  'readingTime' | 'wordsCount'
-> & {
+export type MDXProjectMeta = Exclude<Meta<'project'>, 'wordsCount'> & {
   intro: string;
   title: string;
 };
diff --git a/src/ts/types/raw-data.ts b/src/ts/types/raw-data.ts
index ba6d596..dc3db90 100644
--- a/src/ts/types/raw-data.ts
+++ b/src/ts/types/raw-data.ts
@@ -25,7 +25,6 @@ export type ContentParts = {
 };
 
 export type Info = {
-  readingTime: number;
   wordsCount: number;
 };
 
@@ -66,7 +65,7 @@ export type RawArticlePreview = Pick<
 >;
 
 export type RawPage = {
-  author: NodeResponse<RawAuthor<'page'>>;
+  author?: NodeResponse<RawAuthor<'page'>>;
   contentParts: ContentParts;
   databaseId: number;
   date: string;
diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.ts
index 62337db..1a388e1 100644
--- a/src/utils/helpers/pages.ts
+++ b/src/utils/helpers/pages.ts
@@ -1,7 +1,10 @@
 import { type Post } from '@components/organisms/layout/posts-list';
 import { type LinksListItems } from '@components/organisms/widgets/links-list-widget';
-import { type Meta, type PageLink } from '@ts/types/app';
+import { type EdgesResponse } from '@services/graphql/api';
+import { getArticleFromRawData } from '@services/graphql/articles';
+import { type Article, type PageLink } from '@ts/types/app';
 import {
+  type RawArticle,
   type RawThematicPreview,
   type RawTopicPreview,
 } from '@ts/types/raw-data';
@@ -52,23 +55,35 @@ export const getLinksListItems = (
 };
 
 /**
- * Retrieve the formatted meta.
+ * Retrieve the posts list with the article URL.
  *
- * @param {Meta<'article'>} meta - The article meta.
- * @returns {Post['meta']} The formatted meta.
+ * @param {Article[]} posts - An array of articles.
+ * @returns {Post[]} An array of posts with full article URL.
  */
-export const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-  const { commentsCount, dates, thematics, topics, wordsCount } = meta;
+export const getPostsWithUrl = (posts: Article[]): Post[] => {
+  return posts.map((post) => {
+    return {
+      ...post,
+      url: `/article/${post.slug}`,
+    };
+  });
+};
 
-  return {
-    commentsCount,
-    dates,
-    readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-    thematics: thematics?.map((thematic) => {
-      return { ...thematic, url: `/thematique/${thematic.slug}` };
-    }),
-    topics: topics?.map((topic) => {
-      return { ...topic, url: `/sujet/${topic.slug}` };
-    }),
-  };
+/**
+ * Retrieve the posts list from raw data.
+ *
+ * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
+ * @returns {Post[]} An array of posts.
+ */
+export const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
+  const articlesList: RawArticle[] = [];
+  rawData.forEach((articleData) =>
+    articleData.edges.forEach((edge) => {
+      articlesList.push(edge.node);
+    })
+  );
+
+  return getPostsWithUrl(
+    articlesList.map((article) => getArticleFromRawData(article))
+  );
 };
-- 
cgit v1.2.3


From 5ebd7c14f7303a0feb8ec1d902ecd0e287d929c3 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 23 May 2022 19:02:20 +0200
Subject: refactor(schema): use helpers function to avoid repeat between pages

---
 src/pages/article/[slug].tsx     |  94 +++++++---------
 src/pages/blog/index.tsx         |  49 +++------
 src/pages/blog/page/[number].tsx |  49 +++------
 src/pages/contact.tsx            |  60 ++++-------
 src/pages/cv.tsx                 |  61 ++++-------
 src/pages/index.tsx              |  26 ++---
 src/pages/mentions-legales.tsx   |  62 ++++-------
 src/pages/projets/[slug].tsx     |  60 ++++-------
 src/pages/projets/index.tsx      |  62 ++++-------
 src/pages/recherche/index.tsx    |  49 +++------
 src/pages/sujet/[slug].tsx       |  61 ++++-------
 src/pages/thematique/[slug].tsx  |  60 ++++-------
 src/utils/helpers/schema-org.ts  | 224 +++++++++++++++++++++++++++++++++++++++
 13 files changed, 468 insertions(+), 449 deletions(-)
 create mode 100644 src/utils/helpers/schema-org.ts

(limited to 'src/pages/blog/page/[number].tsx')

diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 2878538..c72d48e 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -18,6 +18,12 @@ import {
   type NextPageWithLayout,
 } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import {
+  getBlogSchema,
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePrism, { type OptionalPrismPlugin } from '@utils/hooks/use-prism';
 import useReadingTime from '@utils/hooks/use-reading-time';
@@ -29,7 +35,6 @@ import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
 import { HTMLAttributes } from 'react';
 import { useIntl } from 'react-intl';
-import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
 import useSWR from 'swr';
 
 type ArticlePageProps = {
@@ -108,64 +113,35 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    lastReviewed: dates.update,
-    name: seo.title,
-    description: seo.description,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const blogSchema: Blog = {
-    '@id': `${website.url}/#blog`,
-    '@type': 'Blog',
-    blogPost: { '@id': `${website.url}/#article` },
-    isPartOf: {
-      '@id': `${pageUrl}`,
-    },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-  };
-
-  const blogPostSchema: BlogPosting = {
-    '@id': `${website.url}/#article`,
-    '@type': 'BlogPosting',
-    name: title,
+  const webpageSchema = getWebPageSchema({
     description: intro,
-    articleBody: content,
-    author: { '@id': `${website.url}/#branding` },
-    commentCount: commentsCount,
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    discussionUrl: `${pageUrl}/#comments`,
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    image: cover?.src,
-    inLanguage: website.locales.default,
-    isPartOf: {
-      '@id': `${website.url}/blog`,
-    },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-    thumbnailUrl: cover?.src,
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, blogSchema, blogPostSchema],
-  };
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+    updateDate: dates.update,
+  });
+  const blogSchema = getBlogSchema({
+    isSinglePage: true,
+    locale: website.locales.default,
+    slug: asPath,
+  });
+  const blogPostSchema = getSinglePageSchema({
+    commentsCount,
+    content,
+    cover: cover?.src,
+    dates,
+    description: intro,
+    id: 'article',
+    kind: 'post',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([
+    webpageSchema,
+    blogSchema,
+    blogPostSchema,
+  ]);
 
   const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers'];
   const { attributes, className } = usePrism({ plugins: prismPlugins });
@@ -202,6 +178,8 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     prismClassNameReplacer
   );
 
+  const pageUrl = `${website.url}${asPath}`;
+
   return (
     <>
       <Head>
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index dd04fad..cfd6f8c 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -23,6 +23,11 @@ import {
   getPageLinkFromRawData,
   getPostsList,
 } from '@utils/helpers/pages';
+import {
+  getBlogSchema,
+  getSchemaJson,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
@@ -31,7 +36,6 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { useIntl } from 'react-intl';
-import { Blog, Graph, WebPage } from 'schema-dts';
 
 type BlogPageProps = {
   articles: EdgesResponse<RawArticle>;
@@ -80,37 +84,18 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     },
     { websiteName: website.name }
   );
-  const pageUrl = `${website.url}${asPath}`;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: pageTitle,
+  const webpageSchema = getWebPageSchema({
     description: pageDescription,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const blogSchema: Blog = {
-    '@id': `${website.url}/#blog`,
-    '@type': 'Blog',
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, blogSchema],
-  };
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const blogSchema = getBlogSchema({
+    isSinglePage: false,
+    locale: website.locales.default,
+    slug: asPath,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
 
   const {
     data,
@@ -149,7 +134,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="website" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={pageDescription} />
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index 8f50ddd..78b1db4 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -26,6 +26,11 @@ import {
   getPageLinkFromRawData,
   getPostsList,
 } from '@utils/helpers/pages';
+import {
+  getBlogSchema,
+  getSchemaJson,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useRedirection from '@utils/hooks/use-redirection';
 import useSettings from '@utils/hooks/use-settings';
@@ -35,7 +40,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
 import { useIntl } from 'react-intl';
-import { Blog, Graph, WebPage } from 'schema-dts';
 
 type BlogPageProps = {
   articles: EdgesResponse<RawArticle>;
@@ -95,37 +99,18 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     },
     { websiteName: website.name }
   );
-  const pageUrl = `${website.url}${asPath}`;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: pageTitle,
+  const webpageSchema = getWebPageSchema({
     description: pageDescription,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const blogSchema: Blog = {
-    '@id': `${website.url}/#blog`,
-    '@type': 'Blog',
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, blogSchema],
-  };
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const blogSchema = getBlogSchema({
+    isSinglePage: false,
+    locale: website.locales.default,
+    slug: asPath,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
 
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Thematics',
@@ -144,7 +129,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="website" />
         <meta property="og:title" content={pageTitleWithPageNumber} />
         <meta property="og:description" content={pageDescription} />
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index c0d6c79..2392fe2 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -6,10 +6,15 @@ import SocialMedia from '@components/organisms/widgets/social-media';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { meta } from '@content/pages/contact.mdx';
-import styles from '@styles/pages/contact.module.scss';
 import { sendMail } from '@services/graphql/contact';
+import styles from '@styles/pages/contact.module.scss';
 import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps } from 'next';
@@ -18,7 +23,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { useState } from 'react';
 import { useIntl } from 'react-intl';
-import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts';
 
 const ContactPage: NextPageWithLayout = () => {
   const { dates, intro, seo, title } = meta;
@@ -36,43 +40,23 @@ const ContactPage: NextPageWithLayout = () => {
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const contactSchema: ContactPageSchema = {
-    '@id': `${website.url}/#contact`,
-    '@type': 'ContactPage',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const contactSchema = getSinglePageSchema({
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, contactSchema],
-  };
+    id: 'contact',
+    kind: 'contact',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, contactSchema]);
 
   const widgets = [
     <SocialMedia
@@ -134,7 +118,7 @@ const ContactPage: NextPageWithLayout = () => {
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={intro} />
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index 3f035d8..4686505 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -11,6 +11,11 @@ import CVContent, { data, meta } from '@content/pages/cv.mdx';
 import styles from '@styles/pages/cv.module.scss';
 import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
@@ -20,7 +25,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import React, { ReactNode } from 'react';
 import { useIntl } from 'react-intl';
-import { AboutPage, Graph, WebPage } from 'schema-dts';
 
 /**
  * CV page.
@@ -106,45 +110,24 @@ const CVPage: NextPageWithLayout = () => {
   ];
 
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const cvSchema: AboutPage = {
-    '@id': `${website.url}/#cv`,
-    '@type': 'AboutPage',
-    name: seo.title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const cvSchema = getSinglePageSchema({
+    cover: image.src,
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    image: image.src,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    thumbnailUrl: image.src,
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, cvSchema],
-  };
+    id: 'cv',
+    kind: 'about',
+    locale: website.locales.default,
+    slug: asPath,
+    title: title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, cvSchema]);
 
   const components: NestedMDXComponents = {
     a: (props) => <Link external={true} {...props} />,
@@ -171,7 +154,7 @@ const CVPage: NextPageWithLayout = () => {
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={intro} />
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index a831ea3..6e9c4c6 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -19,6 +19,7 @@ import { getArticlesCard } from '@services/graphql/articles';
 import styles from '@styles/pages/home.module.scss';
 import { type ArticleCard, type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import { getSchemaJson, getWebPageSchema } from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
@@ -27,7 +28,6 @@ import Head from 'next/head';
 import Script from 'next/script';
 import { ReactElement } from 'react';
 import { useIntl } from 'react-intl';
-import { Graph, WebPage } from 'schema-dts';
 
 type HomeProps = {
   recentPosts: ArticleCard[];
@@ -306,25 +306,13 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
     },
     { websiteName: website.name }
   );
-
-  const webpageSchema: WebPage = {
-    '@id': `${website.url}/#home`,
-    '@type': 'WebPage',
-    name: pageTitle,
+  const webpageSchema = getWebPageSchema({
     description: pageDescription,
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema],
-  };
+    locale: website.locales.default,
+    slug: '',
+    title: pageTitle,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema]);
 
   return (
     <>
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index c8d1772..a58a850 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -7,6 +7,11 @@ import PageLayout, {
 import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx';
 import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
@@ -14,7 +19,6 @@ import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { Article, Graph, WebPage } from 'schema-dts';
 
 /**
  * Legal Notice page.
@@ -44,47 +48,23 @@ const LegalNoticePage: NextPageWithLayout = () => {
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const articleSchema: Article = {
-    '@id': `${website.url}/#legal-notice`,
-    '@type': 'Article',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'legal-notice',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   return (
     <PageLayout
@@ -98,7 +78,7 @@ const LegalNoticePage: NextPageWithLayout = () => {
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={`${seo.title} - ${website.name}`} />
         <meta property="og:description" content={intro} />
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 27c715d..247f350 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -22,6 +22,11 @@ import {
 } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import { capitalize } from '@utils/helpers/strings';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useGithubApi, { type RepoData } from '@utils/hooks/use-github-api';
@@ -33,7 +38,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ComponentType } from 'react';
 import { useIntl } from 'react-intl';
-import { Article, Graph, WebPage } from 'schema-dts';
 
 type ProjectPageProps = {
   project: ProjectPreview;
@@ -66,8 +70,6 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
   const { website } = useSettings();
   const { asPath } = useRouter();
   const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
@@ -137,44 +139,24 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
     technologies,
   };
 
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const articleSchema: Article = {
-    '@id': `${website.url}/project`,
-    '@type': 'Article',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    cover: `/projects/${id}.jpg`,
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    thumbnailUrl: `/projects/${id}.jpg`,
-    image: `/projects/${id}.jpg`,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'project',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   return (
     <>
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 9ca289a..dbca019 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -9,6 +9,11 @@ import styles from '@styles/pages/projects.module.scss';
 import { type NextPageWithLayout, type ProjectCard } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
@@ -16,7 +21,6 @@ import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { Article, Graph, WebPage } from 'schema-dts';
 
 type ProjectsPageProps = {
   projects: ProjectCard[];
@@ -54,54 +58,30 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const articleSchema: Article = {
-    '@id': `${website.url}/#projects`,
-    '@type': 'Article',
-    name: meta.title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    dates,
     description: seo.description,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: meta.title,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'projects',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   return (
     <>
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={`${seo.title} - ${website.name}`} />
         <meta property="og:description" content={seo.description} />
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index c69d931..ab619fb 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -22,6 +22,11 @@ import {
   getPageLinkFromRawData,
   getPostsList,
 } from '@utils/helpers/pages';
+import {
+  getBlogSchema,
+  getSchemaJson,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
 import usePagination from '@utils/hooks/use-pagination';
@@ -31,7 +36,6 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { useIntl } from 'react-intl';
-import { Blog, Graph, WebPage } from 'schema-dts';
 
 type SearchPageProps = {
   thematicsList: RawThematicPreview[];
@@ -87,37 +91,18 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
         },
         { websiteName: website.name }
       );
-  const pageUrl = `${website.url}${asPath}`;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: pageTitle,
+  const webpageSchema = getWebPageSchema({
     description: pageDescription,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const blogSchema: Blog = {
-    '@id': `${website.url}/#blog`,
-    '@type': 'Blog',
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, blogSchema],
-  };
+    locale: website.locales.default,
+    slug: asPath,
+    title: pageTitle,
+  });
+  const blogSchema = getBlogSchema({
+    isSinglePage: false,
+    locale: website.locales.default,
+    slug: asPath,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
 
   const {
     data,
@@ -161,7 +146,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="website" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={pageDescription} />
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 95843cd..838f009 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -24,6 +24,11 @@ import {
   getPageLinkFromRawData,
   getPostsWithUrl,
 } from '@utils/helpers/pages';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps } from 'next';
@@ -32,7 +37,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
 import { useIntl } from 'react-intl';
-import { Article as ArticleSchema, Graph, WebPage } from 'schema-dts';
 
 export type TopicPageProps = {
   currentTopic: Topic;
@@ -68,45 +72,24 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-  };
-
-  const articleSchema: ArticleSchema = {
-    '@id': `${website.url}/#topic`,
-    '@type': 'Article',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    cover: cover?.src,
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    inLanguage: website.locales.default,
-    isPartOf: { '@id': `${website.url}/blog` },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-    subjectOf: { '@id': `${website.url}/blog` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'topic',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   const topicsListTitle = intl.formatMessage({
     defaultMessage: 'Other topics',
@@ -134,7 +117,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
       <Head>
         <title>{seo.title}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={intro} />
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index a91483e..84ab134 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -22,6 +22,11 @@ import {
   getPageLinkFromRawData,
   getPostsWithUrl,
 } from '@utils/helpers/pages';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps } from 'next';
@@ -30,7 +35,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
 import { useIntl } from 'react-intl';
-import { Article as ArticleSchema, Graph, WebPage } from 'schema-dts';
 
 export type ThematicPageProps = {
   currentThematic: Thematic;
@@ -58,45 +62,23 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-  };
-
-  const articleSchema: ArticleSchema = {
-    '@id': `${website.url}/#thematic`,
-    '@type': 'Article',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    inLanguage: website.locales.default,
-    isPartOf: { '@id': `${website.url}/blog` },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-    subjectOf: { '@id': `${website.url}/blog` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'thematic',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Other thematics',
@@ -115,7 +97,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
       <Head>
         <title>{seo.title}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={intro} />
diff --git a/src/utils/helpers/schema-org.ts b/src/utils/helpers/schema-org.ts
new file mode 100644
index 0000000..cdace00
--- /dev/null
+++ b/src/utils/helpers/schema-org.ts
@@ -0,0 +1,224 @@
+import { Dates } from '@ts/types/app';
+import { settings } from '@utils/config';
+import {
+  AboutPage,
+  Article,
+  Blog,
+  BlogPosting,
+  ContactPage,
+  Graph,
+  WebPage,
+} from 'schema-dts';
+
+export type GetBlogSchemaProps = {
+  /**
+   * True if the page is part of the blog.
+   */
+  isSinglePage: boolean;
+  /**
+   * The page locale.
+   */
+  locale: string;
+  /**
+   * The page slug with a leading slash.
+   */
+  slug: string;
+};
+
+/**
+ * Retrieve the JSON for Blog schema.
+ *
+ * @param props - The page data.
+ * @returns {Blog} The JSON for Blog schema.
+ */
+export const getBlogSchema = ({
+  isSinglePage,
+  locale,
+  slug,
+}: GetBlogSchemaProps): Blog => {
+  return {
+    '@id': `${settings.url}/#blog`,
+    '@type': 'Blog',
+    author: { '@id': `${settings.url}/#branding` },
+    creator: { '@id': `${settings.url}/#branding` },
+    editor: { '@id': `${settings.url}/#branding` },
+    blogPost: isSinglePage ? { '@id': `${settings.url}/#article` } : undefined,
+    inLanguage: locale,
+    isPartOf: isSinglePage
+      ? {
+          '@id': `${settings.url}${slug}`,
+        }
+      : undefined,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: isSinglePage
+      ? undefined
+      : { '@id': `${settings.url}${slug}` },
+  };
+};
+
+export type SinglePageSchemaReturn = {
+  about: AboutPage;
+  contact: ContactPage;
+  page: Article;
+  post: BlogPosting;
+};
+
+export type SinglePageSchemaKind = keyof SinglePageSchemaReturn;
+
+export type GetSinglePageSchemaProps<T extends SinglePageSchemaKind> = {
+  /**
+   * The number of comments.
+   */
+  commentsCount?: number;
+  /**
+   * The page content.
+   */
+  content?: string;
+  /**
+   * The url of the cover.
+   */
+  cover?: string;
+  /**
+   * The page dates.
+   */
+  dates: Dates;
+  /**
+   * The page description.
+   */
+  description: string;
+  /**
+   * The page id.
+   */
+  id: string;
+  /**
+   * The page kind.
+   */
+  kind: T;
+  /**
+   * The page locale.
+   */
+  locale: string;
+  /**
+   * The page slug with a leading slash.
+   */
+  slug: string;
+  /**
+   * The page title.
+   */
+  title: string;
+};
+
+/**
+ * Retrieve the JSON schema depending on the page kind.
+ *
+ * @param props - The page data.
+ * @returns {SinglePageSchemaReturn[T]} - Either AboutPage, ContactPage, Article or BlogPosting schema.
+ */
+export const getSinglePageSchema = <T extends SinglePageSchemaKind>({
+  commentsCount,
+  content,
+  cover,
+  dates,
+  description,
+  id,
+  kind,
+  locale,
+  title,
+  slug,
+}: GetSinglePageSchemaProps<T>): SinglePageSchemaReturn[T] => {
+  const publicationDate = new Date(dates.publication);
+  const updateDate = dates.update ? new Date(dates.update) : undefined;
+  const singlePageSchemaType = {
+    about: 'AboutPage',
+    contact: 'ContactPage',
+    page: 'Article',
+    post: 'BlogPosting',
+  };
+
+  return {
+    '@id': `${settings.url}/#${id}`,
+    '@type': singlePageSchemaType[kind],
+    name: title,
+    description,
+    articleBody: content,
+    author: { '@id': `${settings.url}/#branding` },
+    commentCount: commentsCount,
+    copyrightYear: publicationDate.getFullYear(),
+    creator: { '@id': `${settings.url}/#branding` },
+    dateCreated: publicationDate.toISOString(),
+    dateModified: updateDate && updateDate.toISOString(),
+    datePublished: publicationDate.toISOString(),
+    editor: { '@id': `${settings.url}/#branding` },
+    headline: title,
+    image: cover,
+    inLanguage: locale,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    thumbnailUrl: cover,
+    isPartOf:
+      kind === 'post'
+        ? {
+            '@id': `${settings.url}/blog`,
+          }
+        : undefined,
+    mainEntityOfPage: { '@id': `${settings.url}${slug}` },
+  } as SinglePageSchemaReturn[T];
+};
+
+export type GetWebPageSchemaProps = {
+  /**
+   * The page description.
+   */
+  description: string;
+  /**
+   * The page locale.
+   */
+  locale: string;
+  /**
+   * The page slug.
+   */
+  slug: string;
+  /**
+   * The page title.
+   */
+  title: string;
+  /**
+   * The page last update.
+   */
+  updateDate?: string;
+};
+
+/**
+ * Retrieve the JSON for WebPage schema.
+ *
+ * @param props - The page data.
+ * @returns {WebPage} The JSON for WebPage schema.
+ */
+export const getWebPageSchema = ({
+  description,
+  locale,
+  slug,
+  title,
+  updateDate,
+}: GetWebPageSchemaProps): WebPage => {
+  return {
+    '@id': `${settings.url}${slug}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${settings.url}/#breadcrumb` },
+    lastReviewed: updateDate,
+    name: title,
+    description: description,
+    inLanguage: locale,
+    reviewedBy: { '@id': `${settings.url}/#branding` },
+    url: `${settings.url}${slug}`,
+    isPartOf: {
+      '@id': `${settings.url}`,
+    },
+  };
+};
+
+export const getSchemaJson = (graphs: Graph['@graph']): Graph => {
+  return {
+    '@context': 'https://schema.org',
+    '@graph': graphs,
+  };
+};
-- 
cgit v1.2.3


From c51b5d9a5f217f8aa59c4bdcb04eb3c87f5129b3 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 23 May 2022 19:32:42 +0200
Subject: fix: ensure all topics/thematics have the base url

---
 src/components/organisms/layout/posts-list.fixture.tsx |  8 ++++----
 src/components/organisms/layout/summary.fixture.tsx    |  4 ++--
 src/components/organisms/layout/summary.tsx            |  4 ++--
 src/pages/404.tsx                                      |  8 ++++----
 src/pages/article/[slug].tsx                           |  8 ++------
 src/pages/blog/index.tsx                               |  8 ++++----
 src/pages/blog/page/[number].tsx                       |  8 ++++----
 src/pages/recherche/index.tsx                          |  8 ++++----
 src/pages/sujet/[slug].tsx                             |  8 ++++----
 src/pages/thematique/[slug].tsx                        |  9 +++++----
 src/services/graphql/articles.ts                       |  4 ++--
 src/services/graphql/thematics.ts                      |  2 +-
 src/services/graphql/topics.ts                         |  2 +-
 src/ts/types/app.ts                                    |  2 +-
 src/utils/helpers/pages.ts                             | 16 ++++++----------
 15 files changed, 46 insertions(+), 53 deletions(-)

(limited to 'src/pages/blog/page/[number].tsx')

diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx
index b0afdf8..e3f2378 100644
--- a/src/components/organisms/layout/posts-list.fixture.tsx
+++ b/src/components/organisms/layout/posts-list.fixture.tsx
@@ -25,8 +25,8 @@ export const posts: Post[] = [
       dates: { publication: '2022-02-26' },
       wordsCount: introPost1.split(' ').length,
       thematics: [
-        { id: 1, name: 'Cat 1', slug: '#' },
-        { id: 2, name: 'Cat 2', slug: '#' },
+        { id: 1, name: 'Cat 1', url: '#' },
+        { id: 2, name: 'Cat 2', url: '#' },
       ],
       commentsCount: 1,
     },
@@ -39,7 +39,7 @@ export const posts: Post[] = [
     meta: {
       dates: { publication: '2022-02-20' },
       wordsCount: introPost2.split(' ').length,
-      thematics: [{ id: 2, name: 'Cat 2', slug: '#' }],
+      thematics: [{ id: 2, name: 'Cat 2', url: '#' }],
       commentsCount: 0,
     },
     title: 'Debitis laudantium laudantium',
@@ -52,7 +52,7 @@ export const posts: Post[] = [
       cover,
       dates: { publication: '2021-12-20' },
       wordsCount: introPost3.split(' ').length,
-      thematics: [{ id: 1, name: 'Cat 1', slug: '#' }],
+      thematics: [{ id: 1, name: 'Cat 1', url: '#' }],
       commentsCount: 3,
     },
     title: 'Quaerat ut corporis',
diff --git a/src/components/organisms/layout/summary.fixture.tsx b/src/components/organisms/layout/summary.fixture.tsx
index fbc46a5..bb3ebcb 100644
--- a/src/components/organisms/layout/summary.fixture.tsx
+++ b/src/components/organisms/layout/summary.fixture.tsx
@@ -14,8 +14,8 @@ export const meta: SummaryMeta = {
   dates: { publication: '2022-04-11' },
   wordsCount: intro.split(' ').length,
   thematics: [
-    { id: 1, name: 'Cat 1', slug: '#' },
-    { id: 2, name: 'Cat 2', slug: '#' },
+    { id: 1, name: 'Cat 1', url: '#' },
+    { id: 2, name: 'Cat 2', url: '#' },
   ],
   commentsCount: 1,
 };
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index ca51cd6..8807878 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -83,12 +83,12 @@ const Summary: FC<SummaryProps> = ({
           : undefined,
       readingTime,
       thematics: thematics?.map((thematic) => (
-        <Link key={thematic.id} href={thematic.slug}>
+        <Link key={thematic.id} href={thematic.url}>
           {thematic.name}
         </Link>
       )),
       topics: topics?.map((topic) => (
-        <Link key={topic.id} href={topic.slug}>
+        <Link key={topic.id} href={topic.url}>
           {topic.name}
         </Link>
       )),
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 4ab7784..fd7187a 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -98,8 +98,9 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({
           <LinksListWidget
             key="thematics-list"
             items={getLinksListItems(
-              thematicsList.map(getPageLinkFromRawData),
-              'thematic'
+              thematicsList.map((thematic) =>
+                getPageLinkFromRawData(thematic, 'thematic')
+              )
             )}
             title={thematicsListTitle}
             level={2}
@@ -107,8 +108,7 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({
           <LinksListWidget
             key="topics-list"
             items={getLinksListItems(
-              topicsList.map(getPageLinkFromRawData),
-              'topic'
+              topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
             )}
             title={topicsListTitle}
             level={2}
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index c72d48e..5e75ae7 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -81,7 +81,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     thematics:
       thematics &&
       thematics.map((thematic) => (
-        <Link key={thematic.id} href={`/thematique/${thematic.slug}`}>
+        <Link key={thematic.id} href={thematic.url}>
           {thematic.name}
         </Link>
       )),
@@ -99,11 +99,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
       label: footerMetaLabel,
       value: topics.map((topic) => {
         return (
-          <ButtonLink
-            key={topic.id}
-            target={`/sujet/${topic.slug}`}
-            className={styles.btn}
-          >
+          <ButtonLink key={topic.id} target={topic.url} className={styles.btn}>
             {topic.logo && <ResponsiveImage {...topic.logo} />} {topic.name}
           </ButtonLink>
         );
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index cfd6f8c..3956308 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -153,8 +153,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           <LinksListWidget
             key="thematics-list"
             items={getLinksListItems(
-              thematicsList.map(getPageLinkFromRawData),
-              'thematic'
+              thematicsList.map((thematic) =>
+                getPageLinkFromRawData(thematic, 'thematic')
+              )
             )}
             title={thematicsListTitle}
             level={2}
@@ -162,8 +163,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           <LinksListWidget
             key="topics-list"
             items={getLinksListItems(
-              topicsList.map(getPageLinkFromRawData),
-              'topic'
+              topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
             )}
             title={topicsListTitle}
             level={2}
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index 78b1db4..b5efd27 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -148,8 +148,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           <LinksListWidget
             key="thematics-list"
             items={getLinksListItems(
-              thematicsList.map(getPageLinkFromRawData),
-              'thematic'
+              thematicsList.map((thematic) =>
+                getPageLinkFromRawData(thematic, 'thematic')
+              )
             )}
             title={thematicsListTitle}
             level={2}
@@ -157,8 +158,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           <LinksListWidget
             key="topics-list"
             items={getLinksListItems(
-              topicsList.map(getPageLinkFromRawData),
-              'topic'
+              topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
             )}
             title={topicsListTitle}
             level={2}
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index ab619fb..ffdbf44 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -165,8 +165,9 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
           <LinksListWidget
             key="thematics-list"
             items={getLinksListItems(
-              thematicsList.map(getPageLinkFromRawData),
-              'thematic'
+              thematicsList.map((thematic) =>
+                getPageLinkFromRawData(thematic, 'thematic')
+              )
             )}
             title={thematicsListTitle}
             level={2}
@@ -174,8 +175,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
           <LinksListWidget
             key="topics-list"
             items={getLinksListItems(
-              topicsList.map(getPageLinkFromRawData),
-              'topic'
+              topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
             )}
             title={topicsListTitle}
             level={2}
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 838f009..5c86720 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -138,13 +138,13 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
             ? [
                 <LinksListWidget
                   key="related-thematics"
-                  items={getLinksListItems(thematics, 'thematic')}
+                  items={getLinksListItems(thematics)}
                   title={thematicsListTitle}
                   level={2}
                 />,
                 <LinksListWidget
                   key="topics"
-                  items={getLinksListItems(topics, 'topic')}
+                  items={getLinksListItems(topics)}
                   title={topicsListTitle}
                   level={2}
                 />,
@@ -198,10 +198,10 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({
     first: totalTopics,
   });
   const allTopics = allTopicsEdges.edges.map((edge) =>
-    getPageLinkFromRawData(edge.node)
+    getPageLinkFromRawData(edge.node, 'topic')
   );
   const topicsLinks = allTopics.filter(
-    (topic) => topic.slug !== (params!.slug as TopicParams['slug'])
+    (topic) => topic.url !== `/sujet/${params!.slug as TopicParams['slug']}`
   );
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 84ab134..0519731 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -118,13 +118,13 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
             ? [
                 <LinksListWidget
                   key="thematics"
-                  items={getLinksListItems(thematics, 'thematic')}
+                  items={getLinksListItems(thematics)}
                   title={thematicsListTitle}
                   level={2}
                 />,
                 <LinksListWidget
                   key="related-topics"
-                  items={getLinksListItems(topics, 'topic')}
+                  items={getLinksListItems(topics)}
                   title={topicsListTitle}
                   level={2}
                 />,
@@ -178,10 +178,11 @@ export const getStaticProps: GetStaticProps<ThematicPageProps> = async ({
     first: totalThematics,
   });
   const allThematics = allThematicsEdges.edges.map((edge) =>
-    getPageLinkFromRawData(edge.node)
+    getPageLinkFromRawData(edge.node, 'thematic')
   );
   const allThematicsLinks = allThematics.filter(
-    (thematic) => thematic.slug !== (params!.slug as ThematicParams['slug'])
+    (thematic) =>
+      thematic.url !== `/thematique/${params!.slug as ThematicParams['slug']}`
   );
   const translation = await loadTranslation(locale);
 
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index f0ba4e4..27406ac 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -82,10 +82,10 @@ export const getArticleFromRawData = (data: RawArticle): Article => {
         title: seo?.title || '',
       },
       thematics: acfPosts.postsInThematic?.map((thematic) =>
-        getPageLinkFromRawData(thematic)
+        getPageLinkFromRawData(thematic, 'thematic')
       ),
       topics: acfPosts.postsInTopic?.map((topic) =>
-        getPageLinkFromRawData(topic)
+        getPageLinkFromRawData(topic, 'topic')
       ),
       wordsCount: info.wordsCount,
     },
diff --git a/src/services/graphql/thematics.ts b/src/services/graphql/thematics.ts
index fd9d9f5..4dc69e7 100644
--- a/src/services/graphql/thematics.ts
+++ b/src/services/graphql/thematics.ts
@@ -79,7 +79,7 @@ export const getThematicFromRawData = (data: RawThematic): Thematic => {
     posts.forEach((post) => {
       if (post.acfPosts.postsInTopic) {
         post.acfPosts.postsInTopic.forEach((topic) =>
-          topics.push(getPageLinkFromRawData(topic))
+          topics.push(getPageLinkFromRawData(topic, 'topic'))
         );
       }
     });
diff --git a/src/services/graphql/topics.ts b/src/services/graphql/topics.ts
index 277580f..0b1971b 100644
--- a/src/services/graphql/topics.ts
+++ b/src/services/graphql/topics.ts
@@ -80,7 +80,7 @@ export const getTopicFromRawData = (data: RawTopic): Topic => {
     posts.forEach((post) => {
       if (post.acfPosts.postsInThematic) {
         post.acfPosts.postsInThematic.forEach((thematic) =>
-          thematics.push(getPageLinkFromRawData(thematic))
+          thematics.push(getPageLinkFromRawData(thematic, 'thematic'))
         );
       }
     });
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index cb5429b..7bf1541 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -103,7 +103,7 @@ export type PageLink = {
   id: number;
   logo?: Image;
   name: string;
-  slug: string;
+  url: string;
 };
 
 export type Article = Page<'article'>;
diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.ts
index 1a388e1..773d454 100644
--- a/src/utils/helpers/pages.ts
+++ b/src/utils/helpers/pages.ts
@@ -21,15 +21,17 @@ import { getImageFromRawData } from './images';
  * @returns {PageLink} The link data (id, slug and title).
  */
 export const getPageLinkFromRawData = (
-  data: RawThematicPreview | RawTopicPreview
+  data: RawThematicPreview | RawTopicPreview,
+  kind: 'thematic' | 'topic'
 ): PageLink => {
   const { databaseId, featuredImage, slug, title } = data;
+  const baseUrl = kind === 'thematic' ? '/thematique/' : '/sujet/';
 
   return {
     id: databaseId,
     logo: featuredImage ? getImageFromRawData(featuredImage?.node) : undefined,
     name: title,
-    slug,
+    url: `${baseUrl}${slug}`,
   };
 };
 
@@ -37,19 +39,13 @@ export const getPageLinkFromRawData = (
  * Convert page link data to an array of links items.
  *
  * @param {PageLink[]} links - An array of page links.
- * @param {'thematic'|'topic'} kind - The page links kind.
  * @returns {LinksListItem[]} An array of links items.
  */
-export const getLinksListItems = (
-  links: PageLink[],
-  kind: 'thematic' | 'topic'
-): LinksListItems[] => {
-  const baseUrl = kind === 'thematic' ? '/thematique/' : '/sujet/';
-
+export const getLinksListItems = (links: PageLink[]): LinksListItems[] => {
   return links.map((link) => {
     return {
       name: link.name,
-      url: `${baseUrl}${link.slug}`,
+      url: link.url,
     };
   });
 };
-- 
cgit v1.2.3


From 1e982fb02a9967e0efdc76c93a44798a9f2dcb43 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 24 May 2022 16:44:29 +0200
Subject: chore: add a search form when posts list prints no results

---
 .../organisms/layout/no-results.stories.tsx        | 28 ++++++++
 .../organisms/layout/no-results.test.tsx           | 14 ++++
 src/components/organisms/layout/no-results.tsx     | 38 ++++++++++
 .../organisms/layout/posts-list.fixture.tsx        |  2 +
 .../organisms/layout/posts-list.stories.tsx        |  5 +-
 .../organisms/layout/posts-list.test.tsx           | 29 ++++++--
 src/components/organisms/layout/posts-list.tsx     | 81 ++++++++++------------
 .../templates/page/page-layout.stories.tsx         |  7 +-
 src/pages/blog/index.tsx                           |  1 +
 src/pages/blog/page/[number].tsx                   |  1 +
 src/pages/recherche/index.tsx                      |  1 +
 src/pages/sujet/[slug].tsx                         |  5 +-
 src/pages/thematique/[slug].tsx                    |  5 +-
 13 files changed, 164 insertions(+), 53 deletions(-)
 create mode 100644 src/components/organisms/layout/no-results.stories.tsx
 create mode 100644 src/components/organisms/layout/no-results.test.tsx
 create mode 100644 src/components/organisms/layout/no-results.tsx

(limited to 'src/pages/blog/page/[number].tsx')

diff --git a/src/components/organisms/layout/no-results.stories.tsx b/src/components/organisms/layout/no-results.stories.tsx
new file mode 100644
index 0000000..aa2e51e
--- /dev/null
+++ b/src/components/organisms/layout/no-results.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NoResultsComponent from './no-results';
+
+export default {
+  title: 'Organisms/Layout',
+  component: NoResultsComponent,
+  argTypes: {
+    searchPage: {
+      control: {
+        type: 'text',
+      },
+      description: 'The search results page.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
+  },
+} as ComponentMeta<typeof NoResultsComponent>;
+
+const Template: ComponentStory<typeof NoResultsComponent> = (args) => (
+  <NoResultsComponent {...args} />
+);
+
+export const NoResults = Template.bind({});
+NoResults.args = {
+  searchPage: '#',
+};
diff --git a/src/components/organisms/layout/no-results.test.tsx b/src/components/organisms/layout/no-results.test.tsx
new file mode 100644
index 0000000..7f57177
--- /dev/null
+++ b/src/components/organisms/layout/no-results.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@test-utils';
+import NoResults from './no-results';
+
+describe('NoResults', () => {
+  it('renders a no results text', () => {
+    render(<NoResults searchPage="#" />);
+    expect(screen.getByText(/No results/gi)).toBeInTheDocument();
+  });
+
+  it('renders a search form', () => {
+    render(<NoResults searchPage="#" />);
+    expect(screen.getByRole('searchbox')).toBeInTheDocument();
+  });
+});
diff --git a/src/components/organisms/layout/no-results.tsx b/src/components/organisms/layout/no-results.tsx
new file mode 100644
index 0000000..2245dbf
--- /dev/null
+++ b/src/components/organisms/layout/no-results.tsx
@@ -0,0 +1,38 @@
+import SearchForm, {
+  type SearchFormProps,
+} from '@components/organisms/forms/search-form';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+
+export type NoResultsProps = Pick<SearchFormProps, 'searchPage'>;
+
+/**
+ * NoResults component
+ *
+ * Renders a no results text with a search form.
+ */
+const NoResults: FC<NoResultsProps> = ({ searchPage }) => {
+  const intl = useIntl();
+
+  return (
+    <>
+      <p>
+        {intl.formatMessage({
+          defaultMessage: 'No results found.',
+          description: 'NoResults: no results',
+          id: '5O2vpy',
+        })}
+      </p>
+      <p>
+        {intl.formatMessage({
+          defaultMessage: 'Would you like to try a new search?',
+          description: 'NoResults: try a new search message',
+          id: 'DVBwfu',
+        })}
+      </p>
+      <SearchForm hideLabel={true} searchPage={searchPage} />
+    </>
+  );
+};
+
+export default NoResults;
diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx
index e3f2378..97a746f 100644
--- a/src/components/organisms/layout/posts-list.fixture.tsx
+++ b/src/components/organisms/layout/posts-list.fixture.tsx
@@ -59,3 +59,5 @@ export const posts: Post[] = [
     url: '#',
   },
 ];
+
+export const searchPage = '#';
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index 68e3dc0..bff1f28 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -1,6 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import PostsList from './posts-list';
-import { posts } from './posts-list.fixture';
+import { posts, searchPage } from './posts-list.fixture';
 
 /**
  * PostsList - Storybook Meta
@@ -161,6 +161,7 @@ const Template: ComponentStory<typeof PostsList> = (args) => (
 export const Default = Template.bind({});
 Default.args = {
   posts,
+  searchPage,
   total: posts.length,
 };
 
@@ -171,6 +172,7 @@ export const ByYears = Template.bind({});
 ByYears.args = {
   posts,
   byYear: true,
+  searchPage,
   total: posts.length,
 };
 ByYears.decorators = [
@@ -187,5 +189,6 @@ ByYears.decorators = [
 export const NoResults = Template.bind({});
 NoResults.args = {
   posts: [],
+  searchPage,
   total: posts.length,
 };
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
index 1bab466..e58a974 100644
--- a/src/components/organisms/layout/posts-list.test.tsx
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -1,25 +1,46 @@
 import { render, screen } from '@test-utils';
 import PostsList from './posts-list';
-import { posts } from './posts-list.fixture';
+import { posts, searchPage } from './posts-list.fixture';
 
 describe('PostsList', () => {
   it('renders the correct number of posts', () => {
-    render(<PostsList posts={posts} total={posts.length} />);
+    render(
+      <PostsList posts={posts} total={posts.length} searchPage={searchPage} />
+    );
     expect(screen.getAllByRole('article')).toHaveLength(posts.length);
   });
 
   it('renders the number of loaded posts', () => {
-    render(<PostsList posts={posts} total={posts.length} />);
+    render(
+      <PostsList posts={posts} total={posts.length} searchPage={searchPage} />
+    );
     const info = `${posts.length} loaded articles out of a total of ${posts.length}`;
     expect(screen.getByText(info)).toBeInTheDocument();
   });
 
   it('renders a load more button', () => {
     render(
-      <PostsList posts={posts} total={posts.length} showLoadMoreBtn={true} />
+      <PostsList
+        posts={posts}
+        total={posts.length}
+        showLoadMoreBtn={true}
+        searchPage={searchPage}
+      />
     );
     expect(
       screen.getByRole('button', { name: /Load more/i })
     ).toBeInTheDocument();
   });
+
+  it('renders a search form if no results', () => {
+    render(
+      <PostsList
+        posts={[]}
+        total={0}
+        showLoadMoreBtn={true}
+        searchPage={searchPage}
+      />
+    );
+    expect(screen.getByRole('searchbox')).toBeInTheDocument();
+  });
 });
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index 91fc62d..24869fd 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -9,6 +9,7 @@ import useIsMounted from '@utils/hooks/use-is-mounted';
 import useSettings from '@utils/hooks/use-settings';
 import { FC, Fragment, useRef } from 'react';
 import { useIntl } from 'react-intl';
+import NoResults, { NoResultsProps } from './no-results';
 import styles from './posts-list.module.scss';
 import Summary, { type SummaryProps } from './summary';
 
@@ -23,40 +24,41 @@ export type YearCollection = {
   [key: string]: Post[];
 };
 
-export type PostsListProps = Pick<PaginationProps, 'baseUrl' | 'siblings'> & {
-  /**
-   * True to display the posts by year. Default: false.
-   */
-  byYear?: boolean;
-  /**
-   * Determine if the data is loading.
-   */
-  isLoading?: boolean;
-  /**
-   * Load more button handler.
-   */
-  loadMore?: () => void;
-  /**
-   * The current page number. Default: 1.
-   */
-  pageNumber?: number;
-  /**
-   * The posts data.
-   */
-  posts: Post[];
-  /**
-   * Determine if the load more button should be visible.
-   */
-  showLoadMoreBtn?: boolean;
-  /**
-   * The posts heading level (hn).
-   */
-  titleLevel?: HeadingLevel;
-  /**
-   * The total posts number.
-   */
-  total: number;
-};
+export type PostsListProps = Pick<PaginationProps, 'baseUrl' | 'siblings'> &
+  Pick<NoResultsProps, 'searchPage'> & {
+    /**
+     * True to display the posts by year. Default: false.
+     */
+    byYear?: boolean;
+    /**
+     * Determine if the data is loading.
+     */
+    isLoading?: boolean;
+    /**
+     * Load more button handler.
+     */
+    loadMore?: () => void;
+    /**
+     * The current page number. Default: 1.
+     */
+    pageNumber?: number;
+    /**
+     * The posts data.
+     */
+    posts: Post[];
+    /**
+     * Determine if the load more button should be visible.
+     */
+    showLoadMoreBtn?: boolean;
+    /**
+     * The posts heading level (hn).
+     */
+    titleLevel?: HeadingLevel;
+    /**
+     * The total posts number.
+     */
+    total: number;
+  };
 
 /**
  * Create a collection of posts sorted by year.
@@ -89,6 +91,7 @@ const PostsList: FC<PostsListProps> = ({
   loadMore,
   pageNumber = 1,
   posts,
+  searchPage,
   showLoadMoreBtn = false,
   siblings,
   titleLevel,
@@ -221,15 +224,7 @@ const PostsList: FC<PostsListProps> = ({
   };
 
   if (posts.length === 0) {
-    return (
-      <p>
-        {intl.formatMessage({
-          defaultMessage: 'No results found.',
-          description: 'PostsList: no results',
-          id: 'vK7Sxv',
-        })}
-      </p>
-    );
+    return <NoResults searchPage={searchPage} />;
   }
 
   return (
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 88e3785..06c6c24 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -368,7 +368,12 @@ Blog.args = {
   headerMeta: { total: posts.length },
   children: (
     <>
-      <PostsList posts={posts} byYear={true} total={posts.length} />
+      <PostsList
+        posts={posts}
+        byYear={true}
+        total={posts.length}
+        searchPage="#"
+      />
     </>
   ),
   widgets: [
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 3956308..3f7eefd 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -177,6 +177,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
             isLoading={isLoadingMore || isLoadingInitialData}
             loadMore={loadMore}
             posts={getPostsList(data)}
+            searchPage="/recherche/"
             showLoadMoreBtn={hasNextPage}
             total={totalArticles}
           />
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index b5efd27..1e1240a 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -170,6 +170,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           byYear={true}
           pageNumber={pageNumber}
           posts={getPostsList([articles])}
+          searchPage="/recherche/"
           total={totalArticles}
         />
       </PageLayout>
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index ffdbf44..dbbec55 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -189,6 +189,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
             isLoading={isLoadingMore || isLoadingInitialData}
             loadMore={loadMore}
             posts={getPostsList(data)}
+            searchPage="/recherche/"
             showLoadMoreBtn={hasNextPage}
             total={totalArticles || 0}
           />
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 5c86720..48924e5 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -167,10 +167,11 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/sujet/page/"
+              byYear={true}
               posts={getPostsWithUrl(articles)}
-              total={articles.length}
+              searchPage="/recherche/"
               titleLevel={3}
-              byYear={true}
+              total={articles.length}
             />
           </>
         )}
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 0519731..7aa6c1c 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -147,10 +147,11 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/thematique/page/"
+              byYear={true}
               posts={getPostsWithUrl(articles)}
-              total={articles.length}
+              searchPage="/recherche/"
               titleLevel={3}
-              byYear={true}
+              total={articles.length}
             />
           </>
         )}
-- 
cgit v1.2.3

